From b897826634052ef1954dd485fd4300ca29795b1d Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Wed, 27 Nov 2024 16:32:01 +0000 Subject: [PATCH 01/60] chore(): bump build version to LTS --- .nvmrc | 2 +- package-lock.json | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.nvmrc b/.nvmrc index 7ea6a59..bb8c76c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.11.0 +v22.11.0 diff --git a/package-lock.json b/package-lock.json index 1d68ead..8ed0e55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2714,9 +2714,9 @@ "dev": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "devOptional": true, "dependencies": { "path-key": "^3.1.0", @@ -9107,9 +9107,9 @@ "dev": true }, "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "devOptional": true, "requires": { "path-key": "^3.1.0", From d508655136301def05b317771e7a0fa44425d2a8 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Wed, 18 Dec 2024 14:32:42 +0000 Subject: [PATCH 02/60] chore(): refactor ws events into organised dir --- src/types/index.ts | 3 +-- src/types/request/v5-account.ts | 2 +- src/types/request/v5-asset.ts | 2 +- src/types/request/v5-market.ts | 2 +- src/types/request/v5-position.ts | 2 +- src/types/request/v5-pre-upgrade.ts | 2 +- src/types/request/v5-spot-leverage-token.ts | 2 +- src/types/request/v5-trade.ts | 2 +- src/types/request/v5-user.ts | 2 +- src/types/response/v5-account.ts | 2 +- src/types/response/v5-asset.ts | 2 +- src/types/response/v5-market.ts | 2 +- src/types/response/v5-position.ts | 2 +- src/types/response/v5-spot-leverage-token.ts | 2 +- src/types/response/v5-trade.ts | 2 +- src/types/response/v5-user.ts | 2 +- src/types/{v5-shared.ts => shared-v5.ts} | 0 src/types/websockets/index.ts | 2 ++ src/types/websockets/ws-confirmations.ts | 17 +++++++++++++++++ .../ws-events.ts} | 4 ++-- .../{websockets.ts => websockets/ws-general.ts} | 2 +- .../failed-topic-subscription-confirmation.ts | 6 ------ ...succeeded-topic-subscription-confirmation.ts | 6 ------ .../topic-subscription-confirmation.ts | 7 ------- src/util/WsStore.ts | 2 +- src/util/requestUtils.ts | 6 ++++-- src/util/typeGuards.ts | 2 +- src/websocket-client.ts | 4 ++-- 28 files changed, 46 insertions(+), 45 deletions(-) rename src/types/{v5-shared.ts => shared-v5.ts} (100%) create mode 100644 src/types/websockets/index.ts create mode 100644 src/types/websockets/ws-confirmations.ts rename src/types/{websocket.events.ts => websockets/ws-events.ts} (98%) rename src/types/{websockets.ts => websockets/ws-general.ts} (97%) delete mode 100644 src/types/ws-events/failed-topic-subscription-confirmation.ts delete mode 100644 src/types/ws-events/succeeded-topic-subscription-confirmation.ts delete mode 100644 src/types/ws-events/topic-subscription-confirmation.ts diff --git a/src/types/index.ts b/src/types/index.ts index db8ef22..c8094cd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,5 @@ export * from './response'; export * from './request'; export * from './shared'; -export * from './v5-shared'; +export * from './shared-v5'; export * from './websockets'; -export * from './websocket.events'; diff --git a/src/types/request/v5-account.ts b/src/types/request/v5-account.ts index bb39048..dccd289 100644 --- a/src/types/request/v5-account.ts +++ b/src/types/request/v5-account.ts @@ -1,4 +1,4 @@ -import { AccountTypeV5, CategoryV5, TransactionTypeV5 } from '../v5-shared'; +import { AccountTypeV5, CategoryV5, TransactionTypeV5 } from '../shared-v5'; export interface GetWalletBalanceParamsV5 { accountType: AccountTypeV5; diff --git a/src/types/request/v5-asset.ts b/src/types/request/v5-asset.ts index 3de59cd..c7483d7 100644 --- a/src/types/request/v5-asset.ts +++ b/src/types/request/v5-asset.ts @@ -1,4 +1,4 @@ -import { AccountTypeV5, CategoryV5 } from '../v5-shared'; +import { AccountTypeV5, CategoryV5 } from '../shared-v5'; export interface GetCoinExchangeRecordParamsV5 { fromCoin?: string; diff --git a/src/types/request/v5-market.ts b/src/types/request/v5-market.ts index cd02bc9..f5a3c4b 100644 --- a/src/types/request/v5-market.ts +++ b/src/types/request/v5-market.ts @@ -1,5 +1,5 @@ import { KlineIntervalV3 } from '../shared'; -import { CategoryV5, InstrumentStatusV5, OptionTypeV5 } from '../v5-shared'; +import { CategoryV5, InstrumentStatusV5, OptionTypeV5 } from '../shared-v5'; export interface GetKlineParamsV5 { category: 'spot' | 'linear' | 'inverse'; diff --git a/src/types/request/v5-position.ts b/src/types/request/v5-position.ts index 6f1dac0..6e34eea 100644 --- a/src/types/request/v5-position.ts +++ b/src/types/request/v5-position.ts @@ -5,7 +5,7 @@ import { OrderTypeV5, PositionIdx, TPSLModeV5, -} from '../v5-shared'; +} from '../shared-v5'; export interface PositionInfoParamsV5 { category: CategoryV5; diff --git a/src/types/request/v5-pre-upgrade.ts b/src/types/request/v5-pre-upgrade.ts index 3cf65ac..9cc34e2 100644 --- a/src/types/request/v5-pre-upgrade.ts +++ b/src/types/request/v5-pre-upgrade.ts @@ -1,4 +1,4 @@ -import { ExecTypeV5 } from '../v5-shared'; +import { ExecTypeV5 } from '../shared-v5'; export interface GetPreUpgradeOrderHistoryParamsV5 { category: 'linear' | 'inverse'; diff --git a/src/types/request/v5-spot-leverage-token.ts b/src/types/request/v5-spot-leverage-token.ts index 6691dc0..a3a23d9 100644 --- a/src/types/request/v5-spot-leverage-token.ts +++ b/src/types/request/v5-spot-leverage-token.ts @@ -1,4 +1,4 @@ -import { LTOrderTypeV5 } from '../v5-shared'; +import { LTOrderTypeV5 } from '../shared-v5'; export interface PurchaseSpotLeveragedTokenParamsV5 { ltCoin: string; diff --git a/src/types/request/v5-trade.ts b/src/types/request/v5-trade.ts index 123a1a7..437c70c 100644 --- a/src/types/request/v5-trade.ts +++ b/src/types/request/v5-trade.ts @@ -8,7 +8,7 @@ import { OrderTriggerByV5, OrderTypeV5, PositionIdx, -} from '../v5-shared'; +} from '../shared-v5'; export interface OrderParamsV5 { category: CategoryV5; diff --git a/src/types/request/v5-user.ts b/src/types/request/v5-user.ts index 33dd7ec..752ed86 100644 --- a/src/types/request/v5-user.ts +++ b/src/types/request/v5-user.ts @@ -1,4 +1,4 @@ -import { PermissionsV5 } from '../v5-shared'; +import { PermissionsV5 } from '../shared-v5'; export interface CreateSubMemberParamsV5 { username: string; diff --git a/src/types/response/v5-account.ts b/src/types/response/v5-account.ts index 002fe2b..b23884d 100644 --- a/src/types/response/v5-account.ts +++ b/src/types/response/v5-account.ts @@ -4,7 +4,7 @@ import { CategoryV5, TransactionTypeV5, UnifiedUpdateStatusV5, -} from '../v5-shared'; +} from '../shared-v5'; export interface WalletBalanceV5Coin { coin: string; diff --git a/src/types/response/v5-asset.ts b/src/types/response/v5-asset.ts index 9e58065..fe43abf 100644 --- a/src/types/response/v5-asset.ts +++ b/src/types/response/v5-asset.ts @@ -1,4 +1,4 @@ -import { AccountTypeV5, OrderSideV5, WithdrawalTypeV5 } from '../v5-shared'; +import { AccountTypeV5, OrderSideV5, WithdrawalTypeV5 } from '../shared-v5'; export interface CoinExchangeRecordV5 { fromCoin: string; diff --git a/src/types/response/v5-market.ts b/src/types/response/v5-market.ts index a5529fb..f622dca 100644 --- a/src/types/response/v5-market.ts +++ b/src/types/response/v5-market.ts @@ -7,7 +7,7 @@ import { MarginTradingV5, OptionTypeV5, OrderSideV5, -} from '../v5-shared'; +} from '../shared-v5'; /** * OHLCVT candle used by v5 APIs diff --git a/src/types/response/v5-position.ts b/src/types/response/v5-position.ts index 7293bcc..3500c50 100644 --- a/src/types/response/v5-position.ts +++ b/src/types/response/v5-position.ts @@ -9,7 +9,7 @@ import { StopOrderTypeV5, TPSLModeV5, TradeModeV5, -} from '../v5-shared'; +} from '../shared-v5'; export interface PositionV5 { positionIdx: PositionIdx; diff --git a/src/types/response/v5-spot-leverage-token.ts b/src/types/response/v5-spot-leverage-token.ts index 2c1dd94..23cbc60 100644 --- a/src/types/response/v5-spot-leverage-token.ts +++ b/src/types/response/v5-spot-leverage-token.ts @@ -2,7 +2,7 @@ import { LTOrderStatusV5, LTOrderTypeV5, LeverageTokenStatusV5, -} from '../v5-shared'; +} from '../shared-v5'; export interface LeverageTokenInfoV5 { ltCoin: string; diff --git a/src/types/response/v5-trade.ts b/src/types/response/v5-trade.ts index f33a309..8e61cfd 100644 --- a/src/types/response/v5-trade.ts +++ b/src/types/response/v5-trade.ts @@ -10,7 +10,7 @@ import { OrderTypeV5, PositionIdx, StopOrderTypeV5, -} from '../v5-shared'; +} from '../shared-v5'; export interface OrderResultV5 { orderId: string; diff --git a/src/types/response/v5-user.ts b/src/types/response/v5-user.ts index ea8061e..8b4e48c 100644 --- a/src/types/response/v5-user.ts +++ b/src/types/response/v5-user.ts @@ -1,4 +1,4 @@ -import { PermissionsV5 } from '../v5-shared'; +import { PermissionsV5 } from '../shared-v5'; export interface CreateSubMemberResultV5 { uid: string; diff --git a/src/types/v5-shared.ts b/src/types/shared-v5.ts similarity index 100% rename from src/types/v5-shared.ts rename to src/types/shared-v5.ts diff --git a/src/types/websockets/index.ts b/src/types/websockets/index.ts new file mode 100644 index 0000000..b3f48dd --- /dev/null +++ b/src/types/websockets/index.ts @@ -0,0 +1,2 @@ +export * from './ws-general'; +export * from './ws-events'; diff --git a/src/types/websockets/ws-confirmations.ts b/src/types/websockets/ws-confirmations.ts new file mode 100644 index 0000000..cf23649 --- /dev/null +++ b/src/types/websockets/ws-confirmations.ts @@ -0,0 +1,17 @@ +export interface WebsocketTopicSubscriptionConfirmationEvent { + op: 'subscribe'; + req_id: string; + conn_id: string; + ret_msg: string; + success: boolean; +} + +export interface WebsocketSucceededTopicSubscriptionConfirmationEvent + extends WebsocketTopicSubscriptionConfirmationEvent { + success: true; +} + +export interface WebsocketFailedTopicSubscriptionConfirmationEvent + extends WebsocketTopicSubscriptionConfirmationEvent { + success: false; +} diff --git a/src/types/websocket.events.ts b/src/types/websockets/ws-events.ts similarity index 98% rename from src/types/websocket.events.ts rename to src/types/websockets/ws-events.ts index 8c57f7a..493559a 100644 --- a/src/types/websocket.events.ts +++ b/src/types/websockets/ws-events.ts @@ -17,8 +17,8 @@ import { StopOrderTypeV5, TPSLModeV5, TradeModeV5, -} from './v5-shared'; -import { WsKey } from './websockets'; +} from '../shared-v5'; +import { WsKey } from '.'; export interface WSPublicTopicEventV5 { id?: string; diff --git a/src/types/websockets.ts b/src/types/websockets/ws-general.ts similarity index 97% rename from src/types/websockets.ts rename to src/types/websockets/ws-general.ts index 76042b2..cfe77fc 100644 --- a/src/types/websockets.ts +++ b/src/types/websockets/ws-general.ts @@ -1,4 +1,4 @@ -import { RestClientOptions, WS_KEY_MAP } from '../util'; +import { RestClientOptions, WS_KEY_MAP } from '../../util'; /** For spot markets, spotV3 is recommended */ export type APIMarket = diff --git a/src/types/ws-events/failed-topic-subscription-confirmation.ts b/src/types/ws-events/failed-topic-subscription-confirmation.ts deleted file mode 100644 index c8b95d6..0000000 --- a/src/types/ws-events/failed-topic-subscription-confirmation.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { WebsocketTopicSubscriptionConfirmationEvent } from './topic-subscription-confirmation'; - -export interface WebsocketFailedTopicSubscriptionConfirmationEvent - extends WebsocketTopicSubscriptionConfirmationEvent { - success: false; -} diff --git a/src/types/ws-events/succeeded-topic-subscription-confirmation.ts b/src/types/ws-events/succeeded-topic-subscription-confirmation.ts deleted file mode 100644 index 0fc5990..0000000 --- a/src/types/ws-events/succeeded-topic-subscription-confirmation.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { WebsocketTopicSubscriptionConfirmationEvent } from './topic-subscription-confirmation'; - -export interface WebsocketSucceededTopicSubscriptionConfirmationEvent - extends WebsocketTopicSubscriptionConfirmationEvent { - success: true; -} diff --git a/src/types/ws-events/topic-subscription-confirmation.ts b/src/types/ws-events/topic-subscription-confirmation.ts deleted file mode 100644 index 542e63d..0000000 --- a/src/types/ws-events/topic-subscription-confirmation.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface WebsocketTopicSubscriptionConfirmationEvent { - op: 'subscribe'; - req_id: string; - conn_id: string; - ret_msg: string; - success: boolean; -} diff --git a/src/util/WsStore.ts b/src/util/WsStore.ts index 9ca4698..8a257d3 100644 --- a/src/util/WsStore.ts +++ b/src/util/WsStore.ts @@ -38,7 +38,7 @@ interface WsStoredState { subscribedTopics: WsTopicList; } -export default class WsStore { +export class WsStore { private wsState: Record; private logger: typeof DefaultLogger; diff --git a/src/util/requestUtils.ts b/src/util/requestUtils.ts index 2dd199c..1a07691 100644 --- a/src/util/requestUtils.ts +++ b/src/util/requestUtils.ts @@ -1,7 +1,9 @@ import { AxiosResponse } from 'axios'; import { APIRateLimit } from '../types'; -import { WebsocketSucceededTopicSubscriptionConfirmationEvent } from '../types/ws-events/succeeded-topic-subscription-confirmation'; -import { WebsocketTopicSubscriptionConfirmationEvent } from '../types/ws-events/topic-subscription-confirmation'; +import { + WebsocketSucceededTopicSubscriptionConfirmationEvent, + WebsocketTopicSubscriptionConfirmationEvent, +} from '../types/websockets/ws-confirmations'; export interface RestClientOptions { /** Your API key */ diff --git a/src/util/typeGuards.ts b/src/util/typeGuards.ts index dbe78ab..b391930 100644 --- a/src/util/typeGuards.ts +++ b/src/util/typeGuards.ts @@ -7,7 +7,7 @@ import { WSExecutionEventV5, WSOrderbookEventV5, WSPositionEventV5, -} from '../types/websocket.events'; +} from '../types/websockets/ws-events'; /** * Type guard to detect a V5 orderbook event (delta & snapshots) diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 1a76fc5..f922a66 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -20,7 +20,6 @@ import { WsKey, WsTopic, } from './types'; -import { WebsocketTopicSubscriptionConfirmationEvent } from './types/ws-events/topic-subscription-confirmation'; import { UnifiedMarginClient } from './unified-margin-client'; import { USDCOptionClient } from './usdc-option-client'; import { USDCPerpetualClient } from './usdc-perpetual-client'; @@ -42,7 +41,8 @@ import { serializeParams, } from './util'; import { signMessage } from './util/node-support'; -import WsStore from './util/WsStore'; +import { WsStore } from './util/WsStore'; +import { WebsocketTopicSubscriptionConfirmationEvent } from './types/websockets/ws-confirmations'; const loggerCategory = { category: 'bybit-ws' }; From b613fd956d0a2cb0f2241b29cfb1e9caf331944c Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Wed, 18 Dec 2024 14:52:40 +0000 Subject: [PATCH 03/60] feat(): upgrade WsStore to latest iteration --- src/types/websockets/index.ts | 1 + src/util/WsStore.ts | 160 -------- src/util/index.ts | 3 +- src/util/websockets/WsStore.ts | 387 ++++++++++++++++++++ src/util/websockets/WsStore.types.ts | 58 +++ src/util/websockets/index.ts | 3 + src/util/{ => websockets}/websocket-util.ts | 0 src/websocket-client.ts | 8 +- 8 files changed, 454 insertions(+), 166 deletions(-) delete mode 100644 src/util/WsStore.ts create mode 100644 src/util/websockets/WsStore.ts create mode 100644 src/util/websockets/WsStore.types.ts create mode 100644 src/util/websockets/index.ts rename src/util/{ => websockets}/websocket-util.ts (100%) diff --git a/src/types/websockets/index.ts b/src/types/websockets/index.ts index b3f48dd..49058bd 100644 --- a/src/types/websockets/index.ts +++ b/src/types/websockets/index.ts @@ -1,2 +1,3 @@ export * from './ws-general'; export * from './ws-events'; +export * from './ws-confirmations'; diff --git a/src/util/WsStore.ts b/src/util/WsStore.ts deleted file mode 100644 index 8a257d3..0000000 --- a/src/util/WsStore.ts +++ /dev/null @@ -1,160 +0,0 @@ -import WebSocket from 'isomorphic-ws'; -import { WsKey } from '../types'; - -import { DefaultLogger } from './logger'; - -export enum WsConnectionStateEnum { - INITIAL = 0, - CONNECTING = 1, - CONNECTED = 2, - CLOSING = 3, - RECONNECTING = 4, - // ERROR = 5, -} -/** A "topic" is always a string */ -type WsTopic = string; - -/** - * A "Set" is used to ensure we only subscribe to a topic once (tracking a list of unique topics we're expected to be connected to) - * Note: Accurate duplicate tracking only works for plaintext topics. - * E.g. JSON objects may not be seen as duplicates if keys are in different orders. If that's needed, check the FTX implementation. - */ -type WsTopicList = Set; - -interface WsStoredState { - /** The currently active websocket connection */ - ws?: WebSocket; - /** The current lifecycle state of the connection (enum) */ - connectionState?: WsConnectionStateEnum; - /** A timer that will send an upstream heartbeat (ping) when it expires */ - activePingTimer?: ReturnType | undefined; - /** A timer tracking that an upstream heartbeat was sent, expecting a reply before it expires */ - activePongTimer?: ReturnType | undefined; - /** If a reconnection is in progress, this will have the timer for the delayed reconnect */ - activeReconnectTimer?: ReturnType | undefined; - /** - * All the topics we are expected to be subscribed to (and we automatically resubscribe to if the connection drops) - */ - subscribedTopics: WsTopicList; -} - -export class WsStore { - private wsState: Record; - - private logger: typeof DefaultLogger; - - constructor(logger: typeof DefaultLogger) { - this.logger = logger || DefaultLogger; - this.wsState = {}; - } - - /** Get WS stored state for key, optionally create if missing */ - get(key: WsKey, createIfMissing?: true): WsStoredState; - - get(key: WsKey, createIfMissing?: false): WsStoredState | undefined; - - get(key: WsKey, createIfMissing?: boolean): WsStoredState | undefined { - if (this.wsState[key]) { - return this.wsState[key]; - } - - if (createIfMissing) { - return this.create(key); - } - } - - getKeys(): WsKey[] { - return Object.keys(this.wsState) as WsKey[]; - } - - create(key: WsKey): 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: WsConnectionStateEnum.INITIAL, - }; - return this.get(key); - } - - delete(key: WsKey) { - 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: WsKey) { - return this.get(key) && this.isWsOpen(key); - } - - getWs(key: WsKey): WebSocket | undefined { - return this.get(key)?.ws; - } - - setWs(key: WsKey, 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: WsKey): boolean { - const existingConnection = this.getWs(key); - return ( - !!existingConnection && - existingConnection.readyState === existingConnection.OPEN - ); - } - - getConnectionState(key: WsKey): WsConnectionStateEnum { - return this.get(key, true)!.connectionState!; - } - - setConnectionState(key: WsKey, state: WsConnectionStateEnum) { - this.get(key, true)!.connectionState = state; - } - - isConnectionState(key: WsKey, state: WsConnectionStateEnum): boolean { - return this.getConnectionState(key) === state; - } - - /* subscribed topics */ - - getTopics(key: WsKey): WsTopicList { - return this.get(key, true).subscribedTopics; - } - - getTopicsByKey(): Record { - const result = {}; - for (const refKey in this.wsState) { - result[refKey] = this.getTopics(refKey as WsKey); - } - return result; - } - - addTopic(key: WsKey, topic: WsTopic) { - return this.getTopics(key).add(topic); - } - - deleteTopic(key: WsKey, topic: WsTopic) { - return this.getTopics(key).delete(topic); - } -} diff --git a/src/util/index.ts b/src/util/index.ts index 56491b2..3e9ff78 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -2,5 +2,4 @@ export * from './BaseRestClient'; export * from './logger'; export * from './requestUtils'; export * from './typeGuards'; -export * from './websocket-util'; -export * from './WsStore'; +export * from './websockets'; diff --git a/src/util/websockets/WsStore.ts b/src/util/websockets/WsStore.ts new file mode 100644 index 0000000..9022273 --- /dev/null +++ b/src/util/websockets/WsStore.ts @@ -0,0 +1,387 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import WebSocket from 'isomorphic-ws'; + +import { DefaultLogger } from '../logger'; +import { + DeferredPromise, + WSConnectedResult, + WsConnectionStateEnum, + WsStoredState, +} from './WsStore.types'; + +/** + * Simple comparison of two objects, only checks 1-level deep (nested objects won't match) + */ +export function isDeepObjectMatch(object1: unknown, object2: unknown): boolean { + if (typeof object1 === 'string' && typeof object2 === 'string') { + return object1 === object2; + } + + if (typeof object1 !== 'object' || typeof object2 !== 'object') { + return false; + } + + for (const key in object1) { + const value1 = (object1 as any)[key]; + const value2 = (object2 as any)[key]; + + if (value1 !== value2) { + return false; + } + } + return true; +} + +const DEFERRED_PROMISE_REF = { + CONNECTION_IN_PROGRESS: 'CONNECTION_IN_PROGRESS', +} as const; + +type DeferredPromiseRef = + (typeof DEFERRED_PROMISE_REF)[keyof typeof DEFERRED_PROMISE_REF]; + +export class WsStore< + WsKey extends string, + TWSTopicSubscribeEventArgs extends string | object, +> { + private wsState: Record> = + {}; + + private logger: typeof DefaultLogger; + + constructor(logger: typeof DefaultLogger) { + this.logger = logger || DefaultLogger; + } + + /** Get WS stored state for key, optionally create if missing */ + get( + key: WsKey, + createIfMissing?: true, + ): WsStoredState; + + get( + key: WsKey, + createIfMissing?: false, + ): WsStoredState | undefined; + + get( + key: WsKey, + createIfMissing?: boolean, + ): WsStoredState | undefined { + if (this.wsState[key]) { + return this.wsState[key]; + } + + if (createIfMissing) { + return this.create(key); + } + } + + getKeys(): WsKey[] { + return Object.keys(this.wsState) as WsKey[]; + } + + create(key: WsKey): WsStoredState | undefined { + if (this.hasExistingActiveConnection(key)) { + this.logger.info( + 'WsStore setConnection() overwriting existing open connection: ', + this.getWs(key), + ); + } + this.wsState[key] = { + subscribedTopics: new Set(), + connectionState: WsConnectionStateEnum.INITIAL, + deferredPromiseStore: {}, + }; + return this.get(key); + } + + delete(key: WsKey): void { + // TODO: should we allow this at all? Perhaps block this from happening... + if (this.hasExistingActiveConnection(key)) { + const ws = this.getWs(key); + this.logger.info( + 'WsStore deleting state for connection still open: ', + ws, + ); + ws?.close(); + } + delete this.wsState[key]; + } + + /* connection websocket */ + + hasExistingActiveConnection(key: WsKey): boolean { + return this.get(key) && this.isWsOpen(key); + } + + getWs(key: WsKey): WebSocket | undefined { + return this.get(key)?.ws; + } + + setWs(key: WsKey, wsConnection: WebSocket): WebSocket { + if (this.isWsOpen(key)) { + this.logger.info( + 'WsStore setConnection() overwriting existing open connection: ', + this.getWs(key), + ); + } + + this.get(key, true).ws = wsConnection; + return wsConnection; + } + + getDeferredPromise( + wsKey: WsKey, + promiseRef: string | DeferredPromiseRef, + ): DeferredPromise | undefined { + const storeForKey = this.get(wsKey); + if (!storeForKey) { + return; + } + + const deferredPromiseStore = storeForKey.deferredPromiseStore; + return deferredPromiseStore[promiseRef]; + } + + createDeferredPromise( + wsKey: WsKey, + promiseRef: string | DeferredPromiseRef, + throwIfExists: boolean, + ): DeferredPromise { + const existingPromise = this.getDeferredPromise( + wsKey, + promiseRef, + ); + if (existingPromise) { + if (throwIfExists) { + throw new Error(`Promise exists for "${wsKey}"`); + } else { + // console.log('existing promise'); + return existingPromise; + } + } + + // console.log('create promise'); + const createIfMissing = true; + const storeForKey = this.get(wsKey, createIfMissing); + + // TODO: Once stable, use Promise.withResolvers in future + const deferredPromise: DeferredPromise = {}; + + deferredPromise.promise = new Promise((resolve, reject) => { + deferredPromise.resolve = resolve; + deferredPromise.reject = reject; + }); + + const deferredPromiseStore = storeForKey.deferredPromiseStore; + + deferredPromiseStore[promiseRef] = deferredPromise; + + return deferredPromise; + } + + resolveDeferredPromise( + wsKey: WsKey, + promiseRef: string | DeferredPromiseRef, + value: unknown, + removeAfter: boolean, + ): void { + const promise = this.getDeferredPromise(wsKey, promiseRef); + if (promise?.resolve) { + promise.resolve(value); + } + if (removeAfter) { + this.removeDeferredPromise(wsKey, promiseRef); + } + } + + rejectDeferredPromise( + wsKey: WsKey, + promiseRef: string | DeferredPromiseRef, + value: unknown, + removeAfter: boolean, + ): void { + const promise = this.getDeferredPromise(wsKey, promiseRef); + if (promise?.reject) { + promise.reject(value); + } + if (removeAfter) { + this.removeDeferredPromise(wsKey, promiseRef); + } + } + + removeDeferredPromise( + wsKey: WsKey, + promiseRef: string | DeferredPromiseRef, + ): void { + const storeForKey = this.get(wsKey); + if (!storeForKey) { + return; + } + + const deferredPromise = storeForKey.deferredPromiseStore[promiseRef]; + if (deferredPromise) { + // Just in case it's pending + if (deferredPromise.resolve) { + deferredPromise.resolve('promiseRemoved'); + } + + delete storeForKey.deferredPromiseStore[promiseRef]; + } + } + + rejectAllDeferredPromises(wsKey: WsKey, reason: string): void { + const storeForKey = this.get(wsKey); + const deferredPromiseStore = storeForKey.deferredPromiseStore; + if (!storeForKey || !deferredPromiseStore) { + return; + } + + const reservedKeys = Object.values(DEFERRED_PROMISE_REF) as string[]; + + for (const promiseRef in deferredPromiseStore) { + // Skip reserved keys, such as the connection promise + if (reservedKeys.includes(promiseRef)) { + continue; + } + + try { + this.rejectDeferredPromise(wsKey, promiseRef, reason, true); + } catch (e) { + this.logger.error( + 'rejectAllDeferredPromises(): Exception rejecting deferred promise', + { wsKey: wsKey, reason, promiseRef, exception: e }, + ); + } + } + } + + /** Get promise designed to track a connection attempt in progress. Resolves once connected. */ + getConnectionInProgressPromise( + wsKey: WsKey, + ): DeferredPromise | undefined { + return this.getDeferredPromise( + wsKey, + DEFERRED_PROMISE_REF.CONNECTION_IN_PROGRESS, + ); + } + + /** + * Create a deferred promise designed to track a connection attempt in progress. + * + * Will throw if existing promise is found. + */ + createConnectionInProgressPromise( + wsKey: WsKey, + throwIfExists: boolean, + ): DeferredPromise { + return this.createDeferredPromise( + wsKey, + DEFERRED_PROMISE_REF.CONNECTION_IN_PROGRESS, + throwIfExists, + ); + } + + /** Remove promise designed to track a connection attempt in progress */ + removeConnectingInProgressPromise(wsKey: WsKey): void { + return this.removeDeferredPromise( + wsKey, + DEFERRED_PROMISE_REF.CONNECTION_IN_PROGRESS, + ); + } + + /* connection state */ + + isWsOpen(key: WsKey): boolean { + const existingConnection = this.getWs(key); + return ( + !!existingConnection && + existingConnection.readyState === existingConnection.OPEN + ); + } + + getConnectionState(key: WsKey): WsConnectionStateEnum { + return this.get(key, true).connectionState!; + } + + setConnectionState(key: WsKey, state: WsConnectionStateEnum) { + this.get(key, true).connectionState = state; + } + + isConnectionState(key: WsKey, state: WsConnectionStateEnum): boolean { + return this.getConnectionState(key) === state; + } + + /** + * Check if we're currently in the process of opening a connection for any reason. Safer than only checking "CONNECTING" as the state + * @param key + * @returns + */ + isConnectionAttemptInProgress(key: WsKey): boolean { + const isConnectionInProgress = + this.isConnectionState(key, WsConnectionStateEnum.CONNECTING) || + this.isConnectionState(key, WsConnectionStateEnum.RECONNECTING); + + return isConnectionInProgress; + } + + /* subscribed topics */ + + getTopics(key: WsKey): Set { + return this.get(key, true).subscribedTopics; + } + + getTopicsByKey(): Record> { + const result: any = {}; + for (const refKey in this.wsState) { + result[refKey] = this.getTopics(refKey as WsKey); + } + return result; + } + + // Since topics are objects we can't rely on the set to detect duplicates + /** + * Find matching "topic" request from the store + * @param key + * @param topic + * @returns + */ + getMatchingTopic(key: WsKey, topic: TWSTopicSubscribeEventArgs) { + // if (typeof topic === 'string') { + // return this.getMatchingTopic(key, { channel: topic }); + // } + + const allTopics = this.getTopics(key).values(); + for (const storedTopic of allTopics) { + if (isDeepObjectMatch(topic, storedTopic)) { + return storedTopic; + } + } + } + + addTopic(key: WsKey, topic: TWSTopicSubscribeEventArgs) { + // if (typeof topic === 'string') { + // return this.addTopic(key, { + // instType: 'sp', + // channel: topic, + // instId: 'default', + // }; + // } + // Check for duplicate topic. If already tracked, don't store this one + const existingTopic = this.getMatchingTopic(key, topic); + if (existingTopic) { + return this.getTopics(key); + } + return this.getTopics(key).add(topic); + } + + deleteTopic(key: WsKey, topic: TWSTopicSubscribeEventArgs) { + // Check if we're subscribed to a topic like this + const storedTopic = this.getMatchingTopic(key, topic); + if (storedTopic) { + this.getTopics(key).delete(storedTopic); + } + + return this.getTopics(key); + } +} diff --git a/src/util/websockets/WsStore.types.ts b/src/util/websockets/WsStore.types.ts new file mode 100644 index 0000000..d2a4d49 --- /dev/null +++ b/src/util/websockets/WsStore.types.ts @@ -0,0 +1,58 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import WebSocket from 'isomorphic-ws'; + +export enum WsConnectionStateEnum { + INITIAL = 0, + CONNECTING = 1, + CONNECTED = 2, + CLOSING = 3, + RECONNECTING = 4, + // ERROR_RECONNECTING = 5, + // ERROR = 5, +} + +export interface DeferredPromise { + resolve?: (value: TSuccess) => TSuccess; + reject?: (value: TError) => TError; + promise?: Promise; +} + +export interface WSConnectedResult { + wsKey: string; +} + +export interface WsStoredState { + /** The currently active websocket connection */ + ws?: WebSocket; + /** The current lifecycle state of the connection (enum) */ + connectionState?: WsConnectionStateEnum; + /** A timer that will send an upstream heartbeat (ping) when it expires */ + activePingTimer?: ReturnType | undefined; + /** A timer tracking that an upstream heartbeat was sent, expecting a reply before it expires */ + activePongTimer?: ReturnType | undefined; + /** If a reconnection is in progress, this will have the timer for the delayed reconnect */ + activeReconnectTimer?: ReturnType | undefined; + /** + * When a connection attempt is in progress (even for reconnect), a promise is stored here. + * + * This promise will resolve once connected (and will then get removed); + */ + // connectionInProgressPromise?: DeferredPromise | undefined; + deferredPromiseStore: Record; + /** + * All the topics we are expected to be subscribed to on this connection (and we automatically resubscribe to if the connection drops) + * + * A "Set" and a deep-object-match are used to ensure we only subscribe to a + * topic once (tracking a list of unique topics we're expected to be connected to) + */ + subscribedTopics: Set; + /** Whether this connection has completed authentication (only applies to private connections) */ + isAuthenticated?: boolean; + /** + * Whether this connection has completed authentication before for the Websocket API, so it k + * nows to automatically reauth if reconnected + */ + didAuthWSAPI?: boolean; + /** To reauthenticate on the WS API, which channel do we send to? */ + WSAPIAuthChannel?: string; +} diff --git a/src/util/websockets/index.ts b/src/util/websockets/index.ts new file mode 100644 index 0000000..4a06cf6 --- /dev/null +++ b/src/util/websockets/index.ts @@ -0,0 +1,3 @@ +export * from './websocket-util'; +export * from './WsStore'; +export * from './WsStore.types'; diff --git a/src/util/websocket-util.ts b/src/util/websockets/websocket-util.ts similarity index 100% rename from src/util/websocket-util.ts rename to src/util/websockets/websocket-util.ts diff --git a/src/websocket-client.ts b/src/websocket-client.ts index f922a66..3240613 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -17,6 +17,7 @@ import { RESTClient, WSClientConfigurableOptions, WebsocketClientOptions, + WebsocketTopicSubscriptionConfirmationEvent, WsKey, WsTopic, } from './types'; @@ -29,6 +30,7 @@ import { WS_AUTH_ON_CONNECT_KEYS, WS_KEY_MAP, WsConnectionStateEnum, + WsStore, getMaxTopicsPerSubscribeEvent, getWsKeyForTopic, getWsUrl, @@ -41,8 +43,6 @@ import { serializeParams, } from './util'; import { signMessage } from './util/node-support'; -import { WsStore } from './util/WsStore'; -import { WebsocketTopicSubscriptionConfirmationEvent } from './types/websockets/ws-confirmations'; const loggerCategory = { category: 'bybit-ws' }; @@ -104,7 +104,7 @@ export class WebsocketClient extends EventEmitter { private options: WebsocketClientOptions; - private wsStore: WsStore; + private wsStore: WsStore; private pendingTopicsSubscriptions: TopicsPendingSubscriptions[] = []; @@ -139,7 +139,7 @@ export class WebsocketClient extends EventEmitter { } /** Get the WsStore that tracks websockets & topics */ - public getWsStore(): WsStore { + public getWsStore(): WsStore { return this.wsStore; } From 8a7c8ea2743fa3919c516864bc5f53433f23a3a3 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Thu, 16 Jan 2025 16:47:09 +0000 Subject: [PATCH 04/60] feat(): upgrade WebSocket layer to extend BaseWS abstraction. feat(): add promisified WS workflows, feat(): add WS API integration --- src/types/websockets/ws-api.ts | 136 ++ src/types/websockets/ws-events.ts | 20 +- src/types/websockets/ws-general.ts | 55 +- src/util/BaseWSClient.ts | 1305 ++++++++++++++++++ src/util/logger.ts | 15 +- src/util/requestUtils.ts | 15 + src/util/websockets/WsStore.ts | 30 +- src/util/websockets/websocket-util.ts | 362 ++--- src/websocket-client.ts | 1774 +++++++++++-------------- 9 files changed, 2512 insertions(+), 1200 deletions(-) create mode 100644 src/types/websockets/ws-api.ts create mode 100644 src/util/BaseWSClient.ts diff --git a/src/types/websockets/ws-api.ts b/src/types/websockets/ws-api.ts new file mode 100644 index 0000000..ac52177 --- /dev/null +++ b/src/types/websockets/ws-api.ts @@ -0,0 +1,136 @@ +import { APIID, WS_KEY_MAP } from '../../util'; +import { + AmendOrderParamsV5, + CancelOrderParamsV5, + OrderParamsV5, +} from '../request'; +import { WsKey } from './ws-general'; + +export type WSAPIOperation = 'order.create' | 'order.amend' | 'order.cancel'; + +export type WsOperation = + | 'subscribe' + | 'unsubscribe' + | 'auth' + | 'ping' + | 'pong'; + +export const WS_API_Operations: WSAPIOperation[] = [ + 'order.create', + 'order.amend', + 'order.cancel', +]; + +export interface WsRequestOperationBybit< + TWSTopic extends string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + // TWSPayload = any, +> { + req_id: string; + op: WsOperation; + args?: (TWSTopic | string | number)[]; + // payload?: TWSPayload; +} + +export interface WSAPIRequest< + TRequestParams = undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TWSOperation extends WSAPIOperation = any, +> { + reqId: string; + op: TWSOperation; + header: { + 'X-BAPI-TIMESTAMP': string; + 'X-BAPI-RECV-WINDOW': string; + Referer: typeof APIID; + }; + args: [TRequestParams]; +} + +export interface WsAPIWsKeyTopicMap { + [WS_KEY_MAP.v5PrivateTrade]: WSAPIOperation; +} + +export interface WsAPITopicRequestParamMap { + 'order.create': OrderParamsV5; + 'order.amend': AmendOrderParamsV5; + 'order.cancel': CancelOrderParamsV5; + // ping: undefined; +} + +export type WsAPITopicRequestParams = + WsAPITopicRequestParamMap[keyof WsAPITopicRequestParamMap]; + +export interface WSAPIResponse< + TResponseData extends object = object, + TOperation extends WSAPIOperation = WSAPIOperation, +> { + wsKey: WsKey; + /** Auto-generated */ + reqId: string; + retCode: 0 | number; + retMsg: 'OK' | string; + op: TOperation; + data: [TResponseData]; + header?: { + 'X-Bapi-Limit': string; + 'X-Bapi-Limit-Status': string; + 'X-Bapi-Limit-Reset-Timestamp': string; + Traceid: string; + Timenow: string; + }; + connId: string; +} + +// export interface WsAPIResponseMap { +// 'spot.login': WSAPIResponse; +// 'futures.login': WSAPIResponse; +// string: object; +// } + +export interface WsAPIOperationResponseMap< + TResponseType extends object = object, +> { + 'order.create': WSAPIResponse; + 'order.amend': WSAPIResponse; + 'order.cancel': WSAPIResponse; + ping: { + retCode: 0 | number; + retMsg: 'OK' | string; + op: 'pong'; + data: [string]; + connId: string; + }; + + // 'spot.login': WSAPIResponse; + // 'futures.login': WSAPIResponse; + + // 'spot.order_place': WSAPIResponse; + // 'spot.order_cancel': WSAPIResponse; + // 'spot.order_cancel_ids': WSAPIResponse< + // TResponseType, + // 'spot.order_cancel_ids' + // >; + // 'spot.order_cancel_cp': WSAPIResponse; + // 'spot.order_amend': WSAPIResponse; + // 'spot.order_status': WSAPIResponse< + // WSAPIOrderStatusResponse, + // 'spot.order_status' + // >; + // 'futures.order_place': WSAPIResponse; + // 'futures.order_batch_place': WSAPIResponse< + // TResponseType[], + // 'futures.order_batch_place' + // >; + // 'futures.order_cancel': WSAPIResponse; + // 'futures.order_cancel_cp': WSAPIResponse< + // TResponseType, + // 'futures.order_cancel_cp' + // >; + // 'futures.order_amend': WSAPIResponse; + // 'futures.order_list': WSAPIResponse; + // 'futures.order_status': WSAPIResponse< + // WSAPIOrderStatusResponse, + // 'futures.order_status' + // >; +} diff --git a/src/types/websockets/ws-events.ts b/src/types/websockets/ws-events.ts index 493559a..d3f7734 100644 --- a/src/types/websockets/ws-events.ts +++ b/src/types/websockets/ws-events.ts @@ -1,3 +1,5 @@ +import WebSocket from 'isomorphic-ws'; + import { CategoryV5, ExecTypeV5, @@ -18,7 +20,23 @@ import { TPSLModeV5, TradeModeV5, } from '../shared-v5'; -import { WsKey } from '.'; + +import { WsKey } from './ws-general'; + +export interface MessageEventLike { + target: WebSocket; + type: 'message'; + data: string; +} + +export function isMessageEvent(msg: unknown): msg is MessageEventLike { + if (typeof msg !== 'object' || !msg) { + return false; + } + + const message = msg as MessageEventLike; + return message['type'] === 'message' && typeof message['data'] === 'string'; +} export interface WSPublicTopicEventV5 { id?: string; diff --git a/src/types/websockets/ws-general.ts b/src/types/websockets/ws-general.ts index cfe77fc..26f3fbb 100644 --- a/src/types/websockets/ws-general.ts +++ b/src/types/websockets/ws-general.ts @@ -82,10 +82,23 @@ export type WsTopic = WsPublicTopics | WsPrivateTopic; /** This is used to differentiate between each of the available websocket streams (as bybit has multiple websockets) */ export type WsKey = (typeof WS_KEY_MAP)[keyof typeof WS_KEY_MAP]; +export type WsMarket = 'all'; export interface WSClientConfigurableOptions { + /** Your API key */ key?: string; + + /** Your API secret */ secret?: string; + + /** + * Set to `true` to connect to Bybit's testnet environment. + * + * Notes: + * + * - If demo trading, `testnet` should be set to false! + * - If testing a strategy, use demo trading instead. Testnet market data is very different from real market conditions. + */ testnet?: boolean; /** @@ -96,28 +109,54 @@ export interface WSClientConfigurableOptions { demoTrading?: boolean; /** - * The API group this client should connect to. + * The API group this client should connect to. The V5 market is currently used by default. * * For the V3 APIs use `v3` as the market (spot/unified margin/usdc/account asset/copy trading) */ - market: APIMarket; + market?: APIMarket; - pongTimeout?: number; - pingInterval?: number; - reconnectTimeout?: number; - /** Override the recv window for authenticating over websockets (default: 5000 ms) */ + /** Define a recv window when preparing a private websocket signature. This is in milliseconds, so 5000 == 5 seconds */ recvWindow?: number; + + /** How often to check if the connection is alive */ + pingInterval?: number; + + /** How long to wait for a pong (heartbeat reply) before assuming the connection is dead */ + pongTimeout?: number; + + /** Delay in milliseconds before respawning the connection */ + reconnectTimeout?: number; + restOptions?: RestClientOptions; // eslint-disable-next-line @typescript-eslint/no-explicit-any requestOptions?: any; wsUrl?: string; - /** If true, fetch server time before trying to authenticate (disabled by default) */ - fetchTimeOffsetBeforeAuth?: boolean; + + /** + * Allows you to provide a custom "signMessage" function, e.g. to use node's much faster createHmac method + * + * Look in the examples folder for a demonstration on using node's createHmac instead. + */ + customSignMessageFn?: (message: string, secret: string) => Promise; + + /** + * If you authenticated the WS API before, automatically try to + * re-authenticate the WS API if you're disconnected/reconnected for any reason. + */ + reauthWSAPIOnReconnect?: boolean; } +/** + * WS configuration that's always defined, regardless of user configuration + * (usually comes from defaults if there's no user-provided values) + */ export interface WebsocketClientOptions extends WSClientConfigurableOptions { market: APIMarket; pongTimeout: number; pingInterval: number; reconnectTimeout: number; + recvWindow: number; + authPrivateConnectionsOnConnect: boolean; + authPrivateRequests: boolean; + reauthWSAPIOnReconnect: boolean; } diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts new file mode 100644 index 0000000..dd923f2 --- /dev/null +++ b/src/util/BaseWSClient.ts @@ -0,0 +1,1305 @@ +/* eslint-disable max-len */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import EventEmitter from 'events'; +import WebSocket from 'isomorphic-ws'; + +import { DefaultLogger } from './logger'; +import { + MessageEventLike, + WSClientConfigurableOptions, + WebsocketClientOptions, + WebsocketTopicSubscriptionConfirmationEvent, + WsMarket, + isMessageEvent, +} from '../types'; +import { DEFERRED_PROMISE_REF, WsStore } from './websockets/WsStore'; +import { + WSConnectedResult, + WS_LOGGER_CATEGORY, + WsConnectionStateEnum, + WsTopicRequest, + WsTopicRequestOrStringTopic, + safeTerminateWs, +} from './websockets'; +import { WsOperation } from '../types/websockets/ws-api'; + +type TopicsPendingSubscriptionsResolver = () => void; +type TopicsPendingSubscriptionsRejector = (reason: string) => void; + +interface TopicsPendingSubscriptions { + wsKey: string; + failedTopicsSubscriptions: Set; + pendingTopicsSubscriptions: Set; + resolver: TopicsPendingSubscriptionsResolver; + rejector: TopicsPendingSubscriptionsRejector; +} + +interface WSClientEventMap { + /** Connection opened. If this connection was previously opened and reconnected, expect the reconnected event instead */ + open: (evt: { wsKey: WsKey; event: any }) => void; + /** Reconnecting a dropped connection */ + reconnect: (evt: { wsKey: WsKey; event: any }) => void; + /** Successfully reconnected a connection that dropped */ + reconnected: (evt: { wsKey: WsKey; event: any }) => void; + /** Connection closed */ + close: (evt: { wsKey: WsKey; event: any }) => void; + /** Received reply to websocket command (e.g. after subscribing to topics) */ + response: (response: any & { wsKey: WsKey }) => void; + /** Received data for topic */ + update: (response: any & { wsKey: WsKey }) => void; + /** Exception from ws client OR custom listeners (e.g. if you throw inside your event handler) */ + exception: (response: any & { wsKey: WsKey }) => void; + error: (response: any & { wsKey: WsKey }) => void; + /** Confirmation that a connection successfully authenticated */ + authenticated: (event: { wsKey: WsKey; event: any }) => void; +} + +export interface EmittableEvent { + eventType: 'response' | 'update' | 'exception' | 'authenticated'; + event: TEvent; +} + +// Type safety for on and emit handlers: https://stackoverflow.com/a/61609010/880837 +export interface BaseWebsocketClient { + on>( + event: U, + listener: WSClientEventMap[U], + ): this; + + emit>( + event: U, + ...args: Parameters[U]> + ): boolean; +} + +/** + * Users can conveniently pass topics as strings or objects (object has topic name + optional params). + * + * This method normalises topics into objects (object has topic name + optional params). + */ +function getNormalisedTopicRequests( + wsTopicRequests: WsTopicRequestOrStringTopic[], +): WsTopicRequest[] { + const normalisedTopicRequests: WsTopicRequest[] = []; + + for (const wsTopicRequest of wsTopicRequests) { + // passed as string, convert to object + if (typeof wsTopicRequest === 'string') { + const topicRequest: WsTopicRequest = { + topic: wsTopicRequest, + payload: undefined, + }; + normalisedTopicRequests.push(topicRequest); + continue; + } + + // already a normalised object, thanks to user + normalisedTopicRequests.push(wsTopicRequest); + } + return normalisedTopicRequests; +} + +/** + * Base WebSocket abstraction layer. Handles connections, tracking each connection as a unique "WS Key" + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export abstract class BaseWebsocketClient< + /** + * The WS connections supported by the client, each identified by a unique primary key + */ + TWSKey extends string, +> extends EventEmitter { + /** + * State store to track a list of topics (topic requests) we are expected to be subscribed to if reconnected + */ + private wsStore: WsStore>; + + protected logger: typeof DefaultLogger; + + protected options: WebsocketClientOptions; + + private wsApiRequestId: number = 0; + + private timeOffsetMs: number = 0; + + private pendingTopicsSubscriptions: TopicsPendingSubscriptions[] = []; + + constructor( + options?: WSClientConfigurableOptions, + logger?: typeof DefaultLogger, + ) { + super(); + + this.logger = logger || DefaultLogger; + this.wsStore = new WsStore(this.logger); + + this.options = { + // Some defaults: + testnet: false, + demoTrading: false, + + // Connect to V5 by default, if not defined by the user + market: 'v5', + + pongTimeout: 1000, + pingInterval: 10000, + reconnectTimeout: 500, + recvWindow: 5000, + + // Automatically send an authentication op/request after a connection opens, for private connections. + authPrivateConnectionsOnConnect: true, + // Individual requests do not require a signature, so this is disabled. + authPrivateRequests: false, + // Automatically re-authenticate the WS API connection, if previously authenticated. TODO: + reauthWSAPIOnReconnect: true, + ...options, + }; + + this.options.restOptions = { + ...this.options.restOptions, + testnet: this.options.testnet, + }; + + // this.prepareRESTClient(); + + // add default error handling so this doesn't crash node (if the user didn't set a handler) + // eslint-disable-next-line @typescript-eslint/no-empty-function + this.on('error', () => {}); + } + + // protected abstract prepareRESTClient(): void; + + /** + * Return true if this wsKey connection should automatically authenticate immediately after connecting + */ + protected abstract isAuthOnConnectWsKey(wsKey: TWSKey): boolean; + + protected abstract sendPingEvent(wsKey: TWSKey, ws: WebSocket): void; + + protected abstract sendPongEvent(wsKey: TWSKey, ws: WebSocket): void; + + protected abstract isWsPing(data: any): boolean; + + protected abstract isWsPong(data: any): boolean; + + protected abstract getWsAuthRequestEvent(wsKey: TWSKey): Promise; + + protected abstract isPrivateTopicRequest( + request: WsTopicRequest, + wsKey: TWSKey, + ): boolean; + + protected abstract getPrivateWSKeys(): TWSKey[]; + + protected abstract getWsUrl(wsKey: TWSKey): Promise; + + protected abstract getMaxTopicsPerSubscribeEvent( + wsKey: TWSKey, + ): number | null; + + /** + * @returns one or more correctly structured request events for performing a operations over WS. This can vary per exchange spec. + */ + protected abstract getWsRequestEvents( + market: WsMarket, + operation: WsOperation, + requests: WsTopicRequest[], + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + wsKey: TWSKey, + ): Promise; + + /** + * Abstraction called to sort ws events into emittable event types (response to a request, data update, etc) + */ + protected abstract resolveEmittableEvents( + wsKey: TWSKey, + event: MessageEventLike, + ): EmittableEvent[]; + + /** + * Request connection of all dependent (public & private) websockets, instead of waiting for automatic connection by library + */ + protected abstract connectAll(): Promise[]; + + protected isPrivateWsKey(wsKey: TWSKey): boolean { + return this.getPrivateWSKeys().includes(wsKey); + } + + /** Returns auto-incrementing request ID, used to track promise references for async requests */ + protected getNewRequestId(): string { + return `${++this.wsApiRequestId}`; + } + + protected abstract sendWSAPIRequest( + wsKey: TWSKey, + channel: string, + params?: any, + ): Promise; + + protected abstract sendWSAPIRequest( + wsKey: TWSKey, + channel: string, + params: any, + ): Promise; + + protected getTimeOffsetMs() { + return this.timeOffsetMs; + } + + protected setTimeOffsetMs(newOffset: number) { + this.timeOffsetMs = newOffset; + } + + protected upsertPendingTopicsSubscriptions( + wsKey: string, + topicKey: string, + resolver: TopicsPendingSubscriptionsResolver, + rejector: TopicsPendingSubscriptionsRejector, + ) { + const existingWsKeyPendingSubscriptions = + this.pendingTopicsSubscriptions.find((s) => s.wsKey === wsKey); + if (!existingWsKeyPendingSubscriptions) { + this.pendingTopicsSubscriptions.push({ + wsKey, + resolver, + rejector, + failedTopicsSubscriptions: new Set(), + pendingTopicsSubscriptions: new Set([topicKey]), + }); + return; + } + + existingWsKeyPendingSubscriptions.pendingTopicsSubscriptions.add(topicKey); + } + + protected removeTopicPendingSubscription(wsKey: string, topicKey: string) { + const existingWsKeyPendingSubscriptions = + this.pendingTopicsSubscriptions.find((s) => s.wsKey === wsKey); + if (existingWsKeyPendingSubscriptions) { + existingWsKeyPendingSubscriptions.pendingTopicsSubscriptions.delete( + topicKey, + ); + if (!existingWsKeyPendingSubscriptions.pendingTopicsSubscriptions.size) { + this.pendingTopicsSubscriptions = + this.pendingTopicsSubscriptions.filter((s) => s.wsKey !== wsKey); + } + } + } + + private clearTopicsPendingSubscriptions(wsKey: string) { + this.pendingTopicsSubscriptions = this.pendingTopicsSubscriptions.filter( + (s) => s.wsKey !== wsKey, + ); + } + + protected updatePendingTopicSubscriptionStatus( + wsKey: string, + msg: WebsocketTopicSubscriptionConfirmationEvent, + isTopicSubscriptionSuccessEvent: boolean, + ) { + const requestsIds = msg.req_id as string; + const pendingTopicsSubscriptions = this.pendingTopicsSubscriptions.find( + (s) => s.wsKey === wsKey, + ); + + if (!pendingTopicsSubscriptions) { + return; + } + + // TODO: this assume we stored topic info in the req_id, no longer the case... cache it in a separate object? + // WARN: + console.warn('updatePendingTopicSubStatus needs update'); + const splitRequestsIds = requestsIds.split(','); + if (!isTopicSubscriptionSuccessEvent) { + splitRequestsIds.forEach((topic) => + pendingTopicsSubscriptions.failedTopicsSubscriptions.add(topic), + ); + } + + splitRequestsIds.forEach((topicKey) => { + this.removeTopicPendingSubscription(wsKey, topicKey); + + if ( + !pendingTopicsSubscriptions.pendingTopicsSubscriptions.size && + !pendingTopicsSubscriptions.failedTopicsSubscriptions.size + ) { + // all topics have been subscribed successfully, so we can resolve the subscription request + pendingTopicsSubscriptions.resolver(); + this.clearTopicsPendingSubscriptions(wsKey); + } + + if ( + !pendingTopicsSubscriptions.pendingTopicsSubscriptions.size && + pendingTopicsSubscriptions.failedTopicsSubscriptions.size + ) { + // not all topics have been subscribed successfully, so we reject the subscription request + // and let the caller handle the situation by providing the list of failed subscriptions requests + const failedSubscriptionsMessage = `(${[ + ...pendingTopicsSubscriptions.failedTopicsSubscriptions, + ].toString()}) failed to subscribe`; + pendingTopicsSubscriptions.rejector(failedSubscriptionsMessage); + this.clearTopicsPendingSubscriptions(wsKey); + } + }); + } + + /** + * Don't call directly! Use subscribe() instead! + * + * Subscribe to one or more topics on a WS connection (identified by WS Key). + * + * - Topics are automatically cached + * - Connections are automatically opened, if not yet connected + * - Authentication is automatically handled + * - Topics are automatically resubscribed to, if something happens to the connection, unless you call unsubsribeTopicsForWsKey(topics, key). + * + * @param wsRequests array of topics to subscribe to + * @param wsKey ws key referring to the ws connection these topics should be subscribed on + */ + protected async subscribeTopicsForWsKey( + wsTopicRequests: WsTopicRequestOrStringTopic[], + wsKey: TWSKey, + ) { + const normalisedTopicRequests = getNormalisedTopicRequests(wsTopicRequests); + + // Store topics, so future automation (post-auth, post-reconnect) has everything needed to resubscribe automatically + for (const topic of normalisedTopicRequests) { + this.wsStore.addTopic(wsKey, topic); + } + + const isConnected = this.wsStore.isConnectionState( + wsKey, + WsConnectionStateEnum.CONNECTED, + ); + + const isConnectionInProgress = + this.wsStore.isConnectionAttemptInProgress(wsKey); + + // start connection process if it hasn't yet begun. Topics are automatically subscribed to on-connect + if (!isConnected && !isConnectionInProgress) { + return this.connect(wsKey); + } + + // Subscribe should happen automatically once connected, nothing to do here after topics are added to wsStore. + if (!isConnected) { + /** + * Are we in the process of connection? Nothing to send yet. + */ + this.logger.trace( + 'WS not connected - requests queued for retry once connected.', + { + ...WS_LOGGER_CATEGORY, + wsKey, + wsTopicRequests, + }, + ); + return; + } + + // We're connected. Check if auth is needed and if already authenticated + const isPrivateConnection = this.isPrivateWsKey(wsKey); + const isAuthenticated = this.wsStore.get(wsKey)?.isAuthenticated; + if (isPrivateConnection && !isAuthenticated) { + /** + * If not authenticated yet and auth is required, don't request topics yet. + * + * Auth should already automatically be in progress, so no action needed from here. Topics will automatically subscribe post-auth success. + */ + return false; + } + + // Finally, request subscription to topics if the connection is healthy and ready + return this.requestSubscribeTopics(wsKey, normalisedTopicRequests); + } + + protected async unsubscribeTopicsForWsKey( + wsTopicRequests: WsTopicRequestOrStringTopic[], + wsKey: TWSKey, + ): Promise { + const normalisedTopicRequests = getNormalisedTopicRequests(wsTopicRequests); + + // Store topics, so future automation (post-auth, post-reconnect) has everything needed to resubscribe automatically + for (const topic of normalisedTopicRequests) { + this.wsStore.deleteTopic(wsKey, topic); + } + + const isConnected = this.wsStore.isConnectionState( + wsKey, + WsConnectionStateEnum.CONNECTED, + ); + + // If not connected, don't need to do anything. + // Removing the topic from the store is enough to stop it from being resubscribed to on reconnect. + if (!isConnected) { + return; + } + + // We're connected. Check if auth is needed and if already authenticated + const isPrivateConnection = this.isPrivateWsKey(wsKey); + const isAuthenticated = this.wsStore.get(wsKey)?.isAuthenticated; + if (isPrivateConnection && !isAuthenticated) { + /** + * If not authenticated yet and auth is required, don't need to do anything. + * We don't subscribe to topics until auth is complete anyway. + */ + return; + } + + // Finally, request subscription to topics if the connection is healthy and ready + return this.requestUnsubscribeTopics(wsKey, normalisedTopicRequests); + } + + /** + * Splits topic requests into two groups, public & private topic requests + */ + private sortTopicRequestsIntoPublicPrivate( + wsTopicRequests: WsTopicRequest[], + wsKey: TWSKey, + ): { + publicReqs: WsTopicRequest[]; + privateReqs: WsTopicRequest[]; + } { + const publicTopicRequests: WsTopicRequest[] = []; + const privateTopicRequests: WsTopicRequest[] = []; + + for (const topic of wsTopicRequests) { + if (this.isPrivateTopicRequest(topic, wsKey)) { + privateTopicRequests.push(topic); + } else { + publicTopicRequests.push(topic); + } + } + + return { + publicReqs: publicTopicRequests, + privateReqs: privateTopicRequests, + }; + } + + /** Get the WsStore that tracks websockets & topics */ + public getWsStore(): WsStore> { + return this.wsStore; + } + + public close(wsKey: TWSKey, force?: boolean) { + this.logger.info('Closing connection', { ...WS_LOGGER_CATEGORY, wsKey }); + this.setWsState(wsKey, WsConnectionStateEnum.CLOSING); + this.clearTimers(wsKey); + + const ws = this.getWs(wsKey); + ws?.close(); + if (force) { + safeTerminateWs(ws); + } + } + + public closeAll(force?: boolean) { + const keys = this.wsStore.getKeys(); + + this.logger.info(`Closing all ws connections: ${keys}`); + keys.forEach((key: TWSKey) => { + this.close(key, force); + }); + } + + public isConnected(wsKey: TWSKey): boolean { + return this.wsStore.isConnectionState( + wsKey, + WsConnectionStateEnum.CONNECTED, + ); + } + + /** + * Request connection to a specific websocket, instead of waiting for automatic connection. + */ + protected async connect( + wsKey: TWSKey, + ): Promise { + try { + if (this.wsStore.isWsOpen(wsKey)) { + this.logger.error( + 'Refused to connect to ws with existing active connection', + { ...WS_LOGGER_CATEGORY, wsKey }, + ); + return { wsKey }; + } + + if ( + this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTING) + ) { + this.logger.error( + 'Refused to connect to ws, connection attempt already active', + { ...WS_LOGGER_CATEGORY, wsKey }, + ); + return; + } + + if ( + !this.wsStore.getConnectionState(wsKey) || + this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.INITIAL) + ) { + this.setWsState(wsKey, WsConnectionStateEnum.CONNECTING); + } + + if (!this.wsStore.getConnectionInProgressPromise(wsKey)) { + this.wsStore.createConnectionInProgressPromise(wsKey, false); + } + + const url = await this.getWsUrl(wsKey); + const ws = this.connectToWsUrl(url, wsKey); + + this.wsStore.setWs(wsKey, ws); + + return this.wsStore.getConnectionInProgressPromise(wsKey)?.promise; + } catch (err) { + this.parseWsError('Connection failed', err, wsKey); + this.reconnectWithDelay(wsKey, this.options.reconnectTimeout!); + } + } + + private connectToWsUrl(url: string, wsKey: TWSKey): WebSocket { + this.logger.trace(`Opening WS connection to URL: ${url}`, { + ...WS_LOGGER_CATEGORY, + wsKey, + }); + + const agent = this.options.requestOptions?.agent; + const ws = new WebSocket(url, undefined, agent ? { agent } : undefined); + + ws.onopen = (event: any) => this.onWsOpen(event, wsKey); + ws.onmessage = (event: any) => this.onWsMessage(event, wsKey, ws); + ws.onerror = (event: any) => + this.parseWsError('Websocket onWsError', event, wsKey); + ws.onclose = (event: any) => this.onWsClose(event, wsKey); + + return ws; + } + + private parseWsError(context: string, error: any, wsKey: TWSKey) { + if (!error.message) { + this.logger.error(`${context} due to unexpected error: `, error); + this.emit('response', { ...error, wsKey }); + this.emit('exception', { ...error, wsKey }); + return; + } + + switch (error.message) { + case 'Unexpected server response: 401': + this.logger.error(`${context} due to 401 authorization failure.`, { + ...WS_LOGGER_CATEGORY, + wsKey, + }); + break; + + default: + if ( + this.wsStore.getConnectionState(wsKey) !== + WsConnectionStateEnum.CLOSING + ) { + this.logger.error( + `${context} due to unexpected response error: "${ + error?.msg || error?.message || error + }"`, + { ...WS_LOGGER_CATEGORY, wsKey, error }, + ); + this.executeReconnectableClose(wsKey, 'unhandled onWsError'); + } else { + this.logger.info( + `${wsKey} socket forcefully closed. Will not reconnect.`, + ); + } + break; + } + + this.emit('response', { ...error, wsKey }); + this.emit('exception', { ...error, wsKey }); + } + + /** Get a signature, build the auth request and send it */ + private async sendAuthRequest(wsKey: TWSKey): Promise { + try { + this.logger.info('Sending auth request...', { + ...WS_LOGGER_CATEGORY, + wsKey, + }); + + await this.assertIsConnected(wsKey); + + if (!this.wsStore.getAuthenticationInProgressPromise(wsKey)) { + this.wsStore.createAuthenticationInProgressPromise(wsKey, false); + } + + const request = await this.getWsAuthRequestEvent(wsKey); + + // console.log('ws auth req', request); + + this.tryWsSend(wsKey, JSON.stringify(request)); + + return this.wsStore.getAuthenticationInProgressPromise(wsKey)?.promise; + } catch (e) { + this.logger.trace(e, { ...WS_LOGGER_CATEGORY, wsKey }); + } + } + + private reconnectWithDelay(wsKey: TWSKey, connectionDelayMs: number) { + this.clearTimers(wsKey); + + if (!this.wsStore.isConnectionAttemptInProgress(wsKey)) { + this.setWsState(wsKey, WsConnectionStateEnum.RECONNECTING); + } + + if (this.wsStore.get(wsKey)?.activeReconnectTimer) { + this.clearReconnectTimer(wsKey); + } + + this.wsStore.get(wsKey, true).activeReconnectTimer = setTimeout(() => { + this.logger.info('Reconnecting to websocket', { + ...WS_LOGGER_CATEGORY, + wsKey, + }); + this.clearReconnectTimer(wsKey); + this.connect(wsKey); + }, connectionDelayMs); + } + + private ping(wsKey: TWSKey) { + if (this.wsStore.get(wsKey, true).activePongTimer) { + return; + } + + this.clearPongTimer(wsKey); + + this.logger.trace('Sending ping', { ...WS_LOGGER_CATEGORY, wsKey }); + const ws = this.wsStore.get(wsKey, true).ws; + + if (!ws) { + this.logger.error( + `Unable to send ping for wsKey "${wsKey}" - no connection found`, + ); + return; + } + this.sendPingEvent(wsKey, ws); + + this.wsStore.get(wsKey, true).activePongTimer = setTimeout( + () => this.executeReconnectableClose(wsKey, 'Pong timeout'), + this.options.pongTimeout, + ); + } + + /** + * Closes a connection, if it's even open. If open, this will trigger a reconnect asynchronously. + * If closed, trigger a reconnect immediately + */ + private executeReconnectableClose(wsKey: TWSKey, reason: string) { + this.logger.info(`${reason} - closing socket to reconnect`, { + ...WS_LOGGER_CATEGORY, + wsKey, + reason, + }); + + const wasOpen = this.wsStore.isWsOpen(wsKey); + + this.clearPingTimer(wsKey); + this.clearPongTimer(wsKey); + + const ws = this.getWs(wsKey); + + if (ws) { + ws.close(); + safeTerminateWs(ws); + } + + if (!wasOpen) { + this.logger.info( + `${reason} - socket already closed - trigger immediate reconnect`, + { + ...WS_LOGGER_CATEGORY, + wsKey, + reason, + }, + ); + this.reconnectWithDelay(wsKey, this.options.reconnectTimeout); + } + } + + private clearTimers(wsKey: TWSKey) { + this.clearPingTimer(wsKey); + this.clearPongTimer(wsKey); + this.clearReconnectTimer(wsKey); + } + + // Send a ping at intervals + private clearPingTimer(wsKey: TWSKey) { + const wsState = this.wsStore.get(wsKey); + if (wsState?.activePingTimer) { + clearInterval(wsState.activePingTimer); + wsState.activePingTimer = undefined; + } + } + + // Expect a pong within a time limit + private clearPongTimer(wsKey: TWSKey) { + const wsState = this.wsStore.get(wsKey); + if (wsState?.activePongTimer) { + clearTimeout(wsState.activePongTimer); + wsState.activePongTimer = undefined; + // this.logger.trace(`Cleared pong timeout for "${wsKey}"`); + } else { + // this.logger.trace(`No active pong timer for "${wsKey}"`); + } + } + + private clearReconnectTimer(wsKey: TWSKey) { + const wsState = this.wsStore.get(wsKey); + if (wsState?.activeReconnectTimer) { + clearTimeout(wsState.activeReconnectTimer); + wsState.activeReconnectTimer = undefined; + } + } + + /** + * Returns a list of string events that can be individually sent upstream to complete subscribing/unsubscribing/etc to these topics + * + * If events are an object, these should be stringified (`return JSON.stringify(event);`) + * Each event returned by this will be sent one at a time + * + * Events are automatically split into smaller batches, by this method, if needed. + */ + protected async getWsOperationEventsForTopics( + topics: WsTopicRequest[], + wsKey: TWSKey, + operation: WsOperation, + ): Promise { + // console.log(new Date(), `called getWsSubscribeEventsForTopics()`, topics); + // console.trace(); + if (!topics.length) { + return []; + } + + // Events that are ready to send (usually stringified JSON) + const jsonStringEvents: string[] = []; + const market: WsMarket = 'all'; + + const maxTopicsPerEvent = this.getMaxTopicsPerSubscribeEvent(wsKey); + if ( + maxTopicsPerEvent && + maxTopicsPerEvent !== null && + topics.length > maxTopicsPerEvent + ) { + for (let i = 0; i < topics.length; i += maxTopicsPerEvent) { + const batch = topics.slice(i, i + maxTopicsPerEvent); + const subscribeRequestEvents = await this.getWsRequestEvents( + market, + operation, + batch, + wsKey, + ); + + for (const event of subscribeRequestEvents) { + jsonStringEvents.push(JSON.stringify(event)); + } + } + + return jsonStringEvents; + } + + const subscribeRequestEvents = await this.getWsRequestEvents( + market, + operation, + topics, + wsKey, + ); + + for (const event of subscribeRequestEvents) { + jsonStringEvents.push(JSON.stringify(event)); + } + return jsonStringEvents; + } + + /** + * Simply builds and sends subscribe events for a list of topics for a ws key + * + * @private Use the `subscribe(topics)` or `subscribeTopicsForWsKey(topics, wsKey)` method to subscribe to topics. + */ + private async requestSubscribeTopics( + wsKey: TWSKey, + topics: WsTopicRequest[], + ) { + if (!topics.length) { + return; + } + + // Automatically splits requests into smaller batches, if needed + const subscribeWsMessages = await this.getWsOperationEventsForTopics( + topics, + wsKey, + 'subscribe', + ); + + this.logger.trace( + `Subscribing to ${topics.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches.`, // Events: "${JSON.stringify(topics)}" + ); + + // console.log(`batches: `, JSON.stringify(subscribeWsMessages, null, 2)); + + for (const wsMessage of subscribeWsMessages) { + // this.logger.trace(`Sending batch via message: "${wsMessage}"`); + this.tryWsSend(wsKey, wsMessage); + } + + this.logger.trace( + `Finished subscribing to ${topics.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches.`, + ); + } + + /** + * Simply builds and sends unsubscribe events for a list of topics for a ws key + * + * @private Use the `unsubscribe(topics)` method to unsubscribe from topics. Send WS message to unsubscribe from topics. + */ + private async requestUnsubscribeTopics( + wsKey: TWSKey, + wsTopicRequests: WsTopicRequest[], + ) { + if (!wsTopicRequests.length) { + return; + } + + const subscribeWsMessages = await this.getWsOperationEventsForTopics( + wsTopicRequests, + wsKey, + 'unsubscribe', + ); + + this.logger.trace( + `Unsubscribing to ${wsTopicRequests.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches. Events: "${JSON.stringify(wsTopicRequests)}"`, + ); + + for (const wsMessage of subscribeWsMessages) { + this.logger.trace(`Sending batch via message: "${wsMessage}"`); + this.tryWsSend(wsKey, wsMessage); + } + + this.logger.trace( + `Finished unsubscribing to ${wsTopicRequests.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches.`, + ); + } + + /** + * Try sending a string event on a WS connection (identified by the WS Key) + */ + public tryWsSend(wsKey: TWSKey, wsMessage: string) { + try { + this.logger.trace('Sending upstream ws message: ', { + ...WS_LOGGER_CATEGORY, + wsMessage, + wsKey, + }); + if (!wsKey) { + throw new Error( + 'Cannot send message due to no known websocket for this wsKey', + ); + } + const ws = this.getWs(wsKey); + if (!ws) { + throw new Error( + `${wsKey} socket not connected yet, call "connectAll()" first then try again when the "open" event arrives`, + ); + } + ws.send(wsMessage); + } catch (e) { + this.logger.error('Failed to send WS message', { + ...WS_LOGGER_CATEGORY, + wsMessage, + wsKey, + exception: e, + }); + } + } + + private async onWsOpen(event: any, wsKey: TWSKey) { + const isFreshConnectionAttempt = this.wsStore.isConnectionState( + wsKey, + WsConnectionStateEnum.CONNECTING, + ); + + const isReconnectionAttempt = this.wsStore.isConnectionState( + wsKey, + WsConnectionStateEnum.RECONNECTING, + ); + + if (isFreshConnectionAttempt) { + this.logger.info('Websocket connected', { + ...WS_LOGGER_CATEGORY, + wsKey, + testnet: this.options.testnet === true, + market: this.options.market, + }); + + this.emit('open', { wsKey, event }); + } else if (isReconnectionAttempt) { + this.logger.info('Websocket reconnected', { + ...WS_LOGGER_CATEGORY, + wsKey, + testnet: this.options.testnet === true, + market: this.options.market, + }); + + this.emit('reconnected', { wsKey, event }); + } + + this.setWsState(wsKey, WsConnectionStateEnum.CONNECTED); + + this.logger.trace('Enabled ping timer', { ...WS_LOGGER_CATEGORY, wsKey }); + this.wsStore.get(wsKey, true)!.activePingTimer = setInterval( + () => this.ping(wsKey), + this.options.pingInterval, + ); + + // Resolve & cleanup deferred "connection attempt in progress" promise + try { + const connectionInProgressPromise = + this.wsStore.getConnectionInProgressPromise(wsKey); + if (connectionInProgressPromise?.resolve) { + connectionInProgressPromise.resolve({ + wsKey, + }); + } + } catch (e) { + this.logger.error( + 'Exception trying to resolve "connectionInProgress" promise', + ); + } + + // Remove before continuing, in case there's more requests queued + this.wsStore.removeConnectingInProgressPromise(wsKey); + + // Reconnect to topics known before it connected + const { privateReqs, publicReqs } = this.sortTopicRequestsIntoPublicPrivate( + [...this.wsStore.getTopics(wsKey)], + wsKey, + ); + + // Request sub to public topics, if any + this.requestSubscribeTopics(wsKey, publicReqs); + + // Request sub to private topics, if auth on connect isn't needed + // Else, this is automatic after authentication is successfully confirmed + if (!this.options.authPrivateConnectionsOnConnect) { + this.requestSubscribeTopics(wsKey, privateReqs); + } + + // Some websockets require an auth packet to be sent after opening the connection + if ( + this.isAuthOnConnectWsKey(wsKey) && + this.options.authPrivateConnectionsOnConnect + ) { + await this.sendAuthRequest(wsKey); + } + + /** + * + * WS API intialisation post-connect + * + */ + // const wsStoredState = this.wsStore.get(wsKey, true); + // const { didAuthWSAPI, WSAPIAuthChannel } = wsStoredState; + + // // If enabled, automatically reauth WS API if reconnected + // if ( + // isReconnectionAttempt && + // this.options.reauthWSAPIOnReconnect && + // didAuthWSAPI && + // WSAPIAuthChannel + // ) { + // this.logger.info( + // 'WS API was authenticated before reconnect - re-authenticating WS API...', + // ); + + // let attempt = 0; + // const maxReAuthAttempts = 5; + + // while (attempt <= maxReAuthAttempts) { + // attempt++; + // try { + // this.logger.trace( + // `try reauthenticate (attempt ${attempt}/${maxReAuthAttempts})`, + // ); + // const loginResult = await this.sendWSAPIRequest( + // wsKey, + // WSAPIAuthChannel, + // ); + // this.logger.trace('reauthenticated!', loginResult); + // break; + // } catch (e) { + // const giveUp = attempt >= maxReAuthAttempts; + + // const suffix = giveUp + // ? 'Max tries reached. Giving up!' + // : 'Trying again...'; + + // this.logger.error( + // `Exception trying to reauthenticate WS API on reconnect... ${suffix}`, + // ); + + // this.emit('exception', { + // wsKey, + // type: 'wsapi.auth', + // reason: `automatic WS API reauth failed after ${attempt} attempts`, + // }); + // } + // } + // } + } + + /** + * Handle subscription to private topics _after_ authentication successfully completes asynchronously. + * + * Only used for exchanges that require auth before sending private topic subscription requests + */ + private onWsAuthenticated(wsKey: TWSKey, event: unknown) { + const wsState = this.wsStore.get(wsKey, true); + wsState.isAuthenticated = true; + + // Resolve & cleanup deferred "connection attempt in progress" promise + try { + const inProgressPromise = + this.wsStore.getAuthenticationInProgressPromise(wsKey); + + if (inProgressPromise?.resolve) { + inProgressPromise.resolve({ + wsKey, + event, + }); + } + } catch (e) { + this.logger.error( + 'Exception trying to resolve "connectionInProgress" promise', + ); + } + + // Remove before continuing, in case there's more requests queued + this.wsStore.removeAuthenticationInProgressPromise(wsKey); + + if (this.options.authPrivateConnectionsOnConnect) { + const topics = [...this.wsStore.getTopics(wsKey)]; + const privateTopics = topics.filter((topic) => + this.isPrivateTopicRequest(topic, wsKey), + ); + + if (privateTopics.length) { + this.subscribeTopicsForWsKey(privateTopics, wsKey); + } + } + } + + private onWsMessage(event: unknown, wsKey: TWSKey, ws: WebSocket) { + try { + // console.log('onMessageRaw: ', (event as any).data); + // any message can clear the pong timer - wouldn't get a message if the ws wasn't working + this.clearPongTimer(wsKey); + + if (this.isWsPong(event)) { + this.logger.trace('Received pong', { + ...WS_LOGGER_CATEGORY, + wsKey, + event: (event as any)?.data, + }); + return; + } + + if (this.isWsPing(event)) { + this.logger.trace('Received ping', { + ...WS_LOGGER_CATEGORY, + wsKey, + event, + }); + this.sendPongEvent(wsKey, ws); + return; + } + + if (isMessageEvent(event)) { + const data = event.data; + const dataType = event.type; + + const emittableEvents = this.resolveEmittableEvents(wsKey, event); + + if (!emittableEvents.length) { + // console.log(`raw event: `, { data, dataType, emittableEvents }); + this.logger.error( + 'Unhandled/unrecognised ws event message - returned no emittable data', + { + ...WS_LOGGER_CATEGORY, + message: data || 'no message', + dataType, + event, + wsKey, + }, + ); + + return this.emit('update', { ...event, wsKey }); + } + + for (const emittable of emittableEvents) { + // if (emittable.event?.op) { + // console.log('emittable: ', emittable); + // } + + if (this.isWsPong(emittable)) { + this.logger.trace('Received pong2', { + ...WS_LOGGER_CATEGORY, + wsKey, + data, + }); + continue; + } + + if (emittable.eventType === 'authenticated') { + this.logger.trace('Successfully authenticated', { + ...WS_LOGGER_CATEGORY, + wsKey, + emittable, + }); + this.emit(emittable.eventType, { ...emittable.event, wsKey }); + this.onWsAuthenticated(wsKey, emittable.event); + continue; + } + + this.emit(emittable.eventType, { ...emittable.event, wsKey }); + } + + return; + } + + this.logger.error( + 'Unhandled/unrecognised ws event message - unexpected message format', + { + ...WS_LOGGER_CATEGORY, + message: event || 'no message', + event, + wsKey, + }, + ); + } catch (e) { + this.logger.error('Failed to parse ws event message', { + ...WS_LOGGER_CATEGORY, + error: e, + event, + wsKey, + }); + } + } + + private onWsClose(event: unknown, wsKey: TWSKey) { + this.logger.info('Websocket connection closed', { + ...WS_LOGGER_CATEGORY, + wsKey, + }); + + if ( + this.wsStore.getConnectionState(wsKey) !== WsConnectionStateEnum.CLOSING + ) { + // clean up any pending promises for this connection + this.getWsStore().rejectAllDeferredPromises( + wsKey, + 'connection lost, reconnecting', + ); + + this.setWsState(wsKey, WsConnectionStateEnum.INITIAL); + + this.reconnectWithDelay(wsKey, this.options.reconnectTimeout!); + this.emit('reconnect', { wsKey, event }); + } else { + // clean up any pending promises for this connection + this.getWsStore().rejectAllDeferredPromises(wsKey, 'disconnected'); + this.setWsState(wsKey, WsConnectionStateEnum.INITIAL); + this.emit('close', { wsKey, event }); + } + } + + private getWs(wsKey: TWSKey) { + return this.wsStore.getWs(wsKey); + } + + private setWsState(wsKey: TWSKey, state: WsConnectionStateEnum) { + this.wsStore.setConnectionState(wsKey, state); + } + + /** + * Promise-driven method to assert that a ws has successfully connected (will await until connection is open) + */ + protected async assertIsConnected(wsKey: TWSKey): Promise { + const isConnected = this.getWsStore().isConnectionState( + wsKey, + WsConnectionStateEnum.CONNECTED, + ); + + if (!isConnected) { + const inProgressPromise = + this.getWsStore().getConnectionInProgressPromise(wsKey); + + // Already in progress? Await shared promise and retry + if (inProgressPromise) { + this.logger.trace( + 'assertIsConnected(): Awaiting EXISTING connection promise...', + ); + await inProgressPromise.promise; + this.logger.trace( + 'assertIsConnected(): EXISTING connection promise resolved!', + ); + return; + } + + // Start connection, it should automatically store/return a promise. + this.logger.trace( + 'assertIsConnected(): Not connected yet...queue await connection...', + ); + + await this.connect(wsKey); + + this.logger.trace( + 'assertIsConnected(): New connection promise resolved! ', + ); + } + } + + /** + * Promise-driven method to assert that a ws has been successfully authenticated (will await until auth is confirmed) + */ + protected async assertIsAuthenticated(wsKey: TWSKey): Promise { + const isConnected = this.getWsStore().isConnectionState( + wsKey, + WsConnectionStateEnum.CONNECTED, + ); + + if (!isConnected) { + await this.assertIsConnected(wsKey); + } + + const inProgressPromise = + this.getWsStore().getAuthenticationInProgressPromise(wsKey); + + // Already in progress? Await shared promise and retry + if (inProgressPromise) { + this.logger.trace( + 'assertIsAuthenticated(): Awaiting EXISTING authentication promise...', + ); + await inProgressPromise.promise; + this.logger.trace( + 'assertIsAuthenticated(): EXISTING authentication promise resolved!', + ); + return; + } + + // Start authentication, it should automatically store/return a promise. + this.logger.trace( + 'assertIsAuthenticated(): Not authenticated yet...queue await authentication...', + ); + + await this.connect(wsKey); + + this.logger.trace( + 'assertIsAuthenticated(): Authentication promise resolved! ', + ); + } +} diff --git a/src/util/logger.ts b/src/util/logger.ts index 68a290e..0c5613e 100644 --- a/src/util/logger.ts +++ b/src/util/logger.ts @@ -4,22 +4,13 @@ export type LogParams = null | any; export const DefaultLogger = { - /** Ping/pong events and other raw messages that might be noisy */ - silly: (...params: LogParams): void => { - // console.log(params); - }, - debug: (...params: LogParams): void => { - console.log(params); - }, - notice: (...params: LogParams): void => { - console.log(params); + /** Ping/pong events and other raw messages that might be noisy. Enable this while troubleshooting. */ + trace: (..._params: LogParams): void => { + // console.log(_params); }, info: (...params: LogParams): void => { console.info(params); }, - warning: (...params: LogParams): void => { - console.error(params); - }, error: (...params: LogParams): void => { console.error(params); }, diff --git a/src/util/requestUtils.ts b/src/util/requestUtils.ts index 1a07691..89c14db 100644 --- a/src/util/requestUtils.ts +++ b/src/util/requestUtils.ts @@ -4,6 +4,7 @@ import { WebsocketSucceededTopicSubscriptionConfirmationEvent, WebsocketTopicSubscriptionConfirmationEvent, } from '../types/websockets/ws-confirmations'; +import { WSAPIResponse, WS_API_Operations } from '../types/websockets/ws-api'; export interface RestClientOptions { /** Your API key */ @@ -199,6 +200,20 @@ export function isTopicSubscriptionConfirmation( return true; } +export function isWSAPIResponse( + msg: unknown, +): msg is Omit { + if (typeof msg !== 'object' || !msg) { + return false; + } + + if (typeof msg['op'] !== 'string') { + return false; + } + + return (WS_API_Operations as string[]).includes(msg['op']); +} + export function isTopicSubscriptionSuccess( msg: unknown, ): msg is WebsocketSucceededTopicSubscriptionConfirmationEvent { diff --git a/src/util/websockets/WsStore.ts b/src/util/websockets/WsStore.ts index 9022273..c4d58d9 100644 --- a/src/util/websockets/WsStore.ts +++ b/src/util/websockets/WsStore.ts @@ -32,8 +32,9 @@ export function isDeepObjectMatch(object1: unknown, object2: unknown): boolean { return true; } -const DEFERRED_PROMISE_REF = { +export const DEFERRED_PROMISE_REF = { CONNECTION_IN_PROGRESS: 'CONNECTION_IN_PROGRESS', + AUTHENTICATION_IN_PROGRESS: 'AUTHENTICATION_IN_PROGRESS', } as const; type DeferredPromiseRef = @@ -266,6 +267,15 @@ export class WsStore< ); } + getAuthenticationInProgressPromise( + wsKey: WsKey, + ): DeferredPromise | undefined { + return this.getDeferredPromise( + wsKey, + DEFERRED_PROMISE_REF.AUTHENTICATION_IN_PROGRESS, + ); + } + /** * Create a deferred promise designed to track a connection attempt in progress. * @@ -282,6 +292,17 @@ export class WsStore< ); } + createAuthenticationInProgressPromise( + wsKey: WsKey, + throwIfExists: boolean, + ): DeferredPromise { + return this.createDeferredPromise( + wsKey, + DEFERRED_PROMISE_REF.AUTHENTICATION_IN_PROGRESS, + throwIfExists, + ); + } + /** Remove promise designed to track a connection attempt in progress */ removeConnectingInProgressPromise(wsKey: WsKey): void { return this.removeDeferredPromise( @@ -290,6 +311,13 @@ export class WsStore< ); } + removeAuthenticationInProgressPromise(wsKey: WsKey): void { + return this.removeDeferredPromise( + wsKey, + DEFERRED_PROMISE_REF.AUTHENTICATION_IN_PROGRESS, + ); + } + /* connection state */ isWsOpen(key: WsKey): boolean { diff --git a/src/util/websockets/websocket-util.ts b/src/util/websockets/websocket-util.ts index b6084d3..e916eda 100644 --- a/src/util/websockets/websocket-util.ts +++ b/src/util/websockets/websocket-util.ts @@ -1,7 +1,141 @@ import WebSocket from 'isomorphic-ws'; +import { + APIMarket, + CategoryV5, + WebsocketClientOptions, + WsKey, +} from '../../types'; -import { APIMarket, CategoryV5, WebsocketClientOptions, WsKey } from '../types'; -import { DefaultLogger } from './logger'; +import { DefaultLogger } from '../logger'; +import { WSAPIRequest } from '../../types/websockets/ws-api'; + +export const WS_LOGGER_CATEGORY = { category: 'bybit-ws' }; + +export const WS_KEY_MAP = { + inverse: 'inverse', + linearPrivate: 'linearPrivate', + linearPublic: 'linearPublic', + spotPrivate: 'spotPrivate', + spotPublic: 'spotPublic', + spotV3Private: 'spotV3Private', + spotV3Public: 'spotV3Public', + usdcOptionPrivate: 'usdcOptionPrivate', + usdcOptionPublic: 'usdcOptionPublic', + usdcPerpPrivate: 'usdcPerpPrivate', + usdcPerpPublic: 'usdcPerpPublic', + unifiedPrivate: 'unifiedPrivate', + unifiedOptionPublic: 'unifiedOptionPublic', + unifiedPerpUSDTPublic: 'unifiedPerpUSDTPublic', + unifiedPerpUSDCPublic: 'unifiedPerpUSDCPublic', + contractUSDTPublic: 'contractUSDTPublic', + contractUSDTPrivate: 'contractUSDTPrivate', + contractInversePublic: 'contractInversePublic', + contractInversePrivate: 'contractInversePrivate', + v5SpotPublic: 'v5SpotPublic', + v5LinearPublic: 'v5LinearPublic', + v5InversePublic: 'v5InversePublic', + v5OptionPublic: 'v5OptionPublic', + v5Private: 'v5Private', + /** + * The V5 Websocket API (for sending orders over WS) + */ + v5PrivateTrade: 'v5PrivateTrade', +} as const; + +export const WS_AUTH_ON_CONNECT_KEYS: WsKey[] = [ + WS_KEY_MAP.spotV3Private, + WS_KEY_MAP.usdcOptionPrivate, + WS_KEY_MAP.usdcPerpPrivate, + WS_KEY_MAP.unifiedPrivate, + WS_KEY_MAP.contractUSDTPrivate, + WS_KEY_MAP.contractInversePrivate, + WS_KEY_MAP.v5Private, + WS_KEY_MAP.v5PrivateTrade, +]; + +export const PUBLIC_WS_KEYS = [ + WS_KEY_MAP.linearPublic, + WS_KEY_MAP.spotPublic, + WS_KEY_MAP.spotV3Public, + WS_KEY_MAP.usdcOptionPublic, + WS_KEY_MAP.usdcPerpPublic, + WS_KEY_MAP.unifiedOptionPublic, + WS_KEY_MAP.unifiedPerpUSDTPublic, + WS_KEY_MAP.unifiedPerpUSDCPublic, + WS_KEY_MAP.contractUSDTPublic, + WS_KEY_MAP.contractInversePublic, + WS_KEY_MAP.v5SpotPublic, + WS_KEY_MAP.v5LinearPublic, + WS_KEY_MAP.v5InversePublic, + WS_KEY_MAP.v5OptionPublic, +] as string[]; + +/** Used to automatically determine if a sub request should be to the public or private ws (when there's two) */ +const PRIVATE_TOPICS = [ + 'stop_order', + 'outboundAccountInfo', + 'executionReport', + 'ticketInfo', + // copy trading apis + 'copyTradePosition', + 'copyTradeOrder', + 'copyTradeExecution', + 'copyTradeWallet', + // usdc options + 'user.openapi.option.position', + 'user.openapi.option.trade', + 'user.order', + 'user.openapi.option.order', + 'user.service', + 'user.openapi.greeks', + 'user.mmp.event', + // usdc perps + 'user.openapi.perp.position', + 'user.openapi.perp.trade', + 'user.openapi.perp.order', + 'user.service', + // unified margin + 'user.position.unifiedAccount', + 'user.execution.unifiedAccount', + 'user.order.unifiedAccount', + 'user.wallet.unifiedAccount', + 'user.greeks.unifiedAccount', + // contract v3 + 'user.position.contractAccount', + 'user.execution.contractAccount', + 'user.order.contractAccount', + 'user.wallet.contractAccount', + // v5 + 'position', + 'execution', + 'order', + 'wallet', + 'greeks', +]; + +/** + * Normalised internal format for a request (subscribe/unsubscribe/etc) on a topic, with optional parameters. + * + * - Topic: the topic this event is for + * - Payload: the parameters to include, optional. E.g. auth requires key + sign. Some topics allow configurable parameters. + * - Category: required for bybit, since different categories have different public endpoints + */ +export interface WsTopicRequest< + TWSTopic extends string = string, + TWSPayload = unknown, +> { + topic: TWSTopic; + payload?: TWSPayload; + category?: CategoryV5; +} + +/** + * Conveniently allow users to request a topic either as string topics or objects (containing string topic + params) + */ +export type WsTopicRequestOrStringTopic< + TWSTopic extends string, + TWSPayload = unknown, +> = WsTopicRequest | string; interface NetworkMapV3 { livenet: string; @@ -33,7 +167,55 @@ export const WS_BASE_URL_MAP: Record< APIMarket, Record > & - Record> = { + Record> & + Record< + typeof WS_KEY_MAP.v5PrivateTrade, + Record + > = { + v5: { + public: { + livenet: 'public topics are routed internally via the public wskeys', + testnet: 'public topics are routed internally via the public wskeys', + }, + private: { + livenet: 'wss://stream.bybit.com/v5/private', + testnet: 'wss://stream-testnet.bybit.com/v5/private', + }, + }, + v5PrivateTrade: { + public: { + livenet: 'public topics are routed internally via the public wskeys', + testnet: 'public topics are routed internally via the public wskeys', + }, + private: { + livenet: 'wss://stream.bybit.com/v5/trade', + testnet: 'wss://stream-testnet.bybit.com/v5/trade', + }, + }, + v5SpotPublic: { + public: { + livenet: 'wss://stream.bybit.com/v5/public/spot', + testnet: 'wss://stream-testnet.bybit.com/v5/public/spot', + }, + }, + v5LinearPublic: { + public: { + livenet: 'wss://stream.bybit.com/v5/public/linear', + testnet: 'wss://stream-testnet.bybit.com/v5/public/linear', + }, + }, + v5InversePublic: { + public: { + livenet: 'wss://stream.bybit.com/v5/public/inverse', + testnet: 'wss://stream-testnet.bybit.com/v5/public/inverse', + }, + }, + v5OptionPublic: { + public: { + livenet: 'wss://stream.bybit.com/v5/public/option', + testnet: 'wss://stream-testnet.bybit.com/v5/public/option', + }, + }, inverse: { public: { livenet: 'wss://stream.bybit.com/realtime', @@ -154,139 +336,8 @@ export const WS_BASE_URL_MAP: Record< testnet: 'wss://stream-testnet.bybit.com/contract/private/v3', }, }, - v5: { - public: { - livenet: 'public topics are routed internally via the public wskeys', - testnet: 'public topics are routed internally via the public wskeys', - }, - private: { - livenet: 'wss://stream.bybit.com/v5/private', - testnet: 'wss://stream-testnet.bybit.com/v5/private', - }, - }, - v5SpotPublic: { - public: { - livenet: 'wss://stream.bybit.com/v5/public/spot', - testnet: 'wss://stream-testnet.bybit.com/v5/public/spot', - }, - }, - v5LinearPublic: { - public: { - livenet: 'wss://stream.bybit.com/v5/public/linear', - testnet: 'wss://stream-testnet.bybit.com/v5/public/linear', - }, - }, - v5InversePublic: { - public: { - livenet: 'wss://stream.bybit.com/v5/public/inverse', - testnet: 'wss://stream-testnet.bybit.com/v5/public/inverse', - }, - }, - v5OptionPublic: { - public: { - livenet: 'wss://stream.bybit.com/v5/public/option', - testnet: 'wss://stream-testnet.bybit.com/v5/public/option', - }, - }, }; -export const WS_KEY_MAP = { - inverse: 'inverse', - linearPrivate: 'linearPrivate', - linearPublic: 'linearPublic', - spotPrivate: 'spotPrivate', - spotPublic: 'spotPublic', - spotV3Private: 'spotV3Private', - spotV3Public: 'spotV3Public', - usdcOptionPrivate: 'usdcOptionPrivate', - usdcOptionPublic: 'usdcOptionPublic', - usdcPerpPrivate: 'usdcPerpPrivate', - usdcPerpPublic: 'usdcPerpPublic', - unifiedPrivate: 'unifiedPrivate', - unifiedOptionPublic: 'unifiedOptionPublic', - unifiedPerpUSDTPublic: 'unifiedPerpUSDTPublic', - unifiedPerpUSDCPublic: 'unifiedPerpUSDCPublic', - contractUSDTPublic: 'contractUSDTPublic', - contractUSDTPrivate: 'contractUSDTPrivate', - contractInversePublic: 'contractInversePublic', - contractInversePrivate: 'contractInversePrivate', - v5SpotPublic: 'v5SpotPublic', - v5LinearPublic: 'v5LinearPublic', - v5InversePublic: 'v5InversePublic', - v5OptionPublic: 'v5OptionPublic', - v5Private: 'v5Private', -} as const; - -export const WS_AUTH_ON_CONNECT_KEYS: WsKey[] = [ - WS_KEY_MAP.spotV3Private, - WS_KEY_MAP.usdcOptionPrivate, - WS_KEY_MAP.usdcPerpPrivate, - WS_KEY_MAP.unifiedPrivate, - WS_KEY_MAP.contractUSDTPrivate, - WS_KEY_MAP.contractInversePrivate, - WS_KEY_MAP.v5Private, -]; - -export const PUBLIC_WS_KEYS = [ - WS_KEY_MAP.linearPublic, - WS_KEY_MAP.spotPublic, - WS_KEY_MAP.spotV3Public, - WS_KEY_MAP.usdcOptionPublic, - WS_KEY_MAP.usdcPerpPublic, - WS_KEY_MAP.unifiedOptionPublic, - WS_KEY_MAP.unifiedPerpUSDTPublic, - WS_KEY_MAP.unifiedPerpUSDCPublic, - WS_KEY_MAP.contractUSDTPublic, - WS_KEY_MAP.contractInversePublic, - WS_KEY_MAP.v5SpotPublic, - WS_KEY_MAP.v5LinearPublic, - WS_KEY_MAP.v5InversePublic, - WS_KEY_MAP.v5OptionPublic, -] as string[]; - -/** Used to automatically determine if a sub request should be to the public or private ws (when there's two) */ -const PRIVATE_TOPICS = [ - 'stop_order', - 'outboundAccountInfo', - 'executionReport', - 'ticketInfo', - // copy trading apis - 'copyTradePosition', - 'copyTradeOrder', - 'copyTradeExecution', - 'copyTradeWallet', - // usdc options - 'user.openapi.option.position', - 'user.openapi.option.trade', - 'user.order', - 'user.openapi.option.order', - 'user.service', - 'user.openapi.greeks', - 'user.mmp.event', - // usdc perps - 'user.openapi.perp.position', - 'user.openapi.perp.trade', - 'user.openapi.perp.order', - 'user.service', - // unified margin - 'user.position.unifiedAccount', - 'user.execution.unifiedAccount', - 'user.order.unifiedAccount', - 'user.wallet.unifiedAccount', - 'user.greeks.unifiedAccount', - // contract v3 - 'user.position.contractAccount', - 'user.execution.contractAccount', - 'user.order.contractAccount', - 'user.wallet.contractAccount', - // v5 - 'position', - 'execution', - 'order', - 'wallet', - 'greeks', -]; - export function isPrivateWsTopic(topic: string): boolean { return PRIVATE_TOPICS.includes(topic); } @@ -416,6 +467,24 @@ export function getWsUrl( const networkKey = isTestnet ? 'testnet' : 'livenet'; switch (wsKey) { + case WS_KEY_MAP.v5Private: { + return WS_BASE_URL_MAP.v5.private[networkKey]; + } + case WS_KEY_MAP.v5PrivateTrade: { + return WS_BASE_URL_MAP[wsKey].private[networkKey]; + } + case WS_KEY_MAP.v5SpotPublic: { + return WS_BASE_URL_MAP.v5SpotPublic.public[networkKey]; + } + case WS_KEY_MAP.v5LinearPublic: { + return WS_BASE_URL_MAP.v5LinearPublic.public[networkKey]; + } + case WS_KEY_MAP.v5InversePublic: { + return WS_BASE_URL_MAP.v5InversePublic.public[networkKey]; + } + case WS_KEY_MAP.v5OptionPublic: { + return WS_BASE_URL_MAP.v5OptionPublic.public[networkKey]; + } case WS_KEY_MAP.linearPublic: { return WS_BASE_URL_MAP.linear.public[networkKey]; } @@ -474,21 +543,6 @@ export function getWsUrl( case WS_KEY_MAP.contractUSDTPublic: { return WS_BASE_URL_MAP.contractUSDT.public[networkKey]; } - case WS_KEY_MAP.v5Private: { - return WS_BASE_URL_MAP.v5.private[networkKey]; - } - case WS_KEY_MAP.v5SpotPublic: { - return WS_BASE_URL_MAP.v5SpotPublic.public[networkKey]; - } - case WS_KEY_MAP.v5LinearPublic: { - return WS_BASE_URL_MAP.v5LinearPublic.public[networkKey]; - } - case WS_KEY_MAP.v5InversePublic: { - return WS_BASE_URL_MAP.v5InversePublic.public[networkKey]; - } - case WS_KEY_MAP.v5OptionPublic: { - return WS_BASE_URL_MAP.v5OptionPublic.public[networkKey]; - } default: { logger.error('getWsUrl(): Unhandled wsKey: ', { category: 'bybit-ws', @@ -569,3 +623,13 @@ export function safeTerminateWs(ws?: WebSocket | unknown) { ws.terminate(); } } +/** + * WS API promises are stored using a primary key. This key is constructed using + * properties found in every request & reply. + */ +export function getPromiseRefForWSAPIRequest( + requestEvent: WSAPIRequest, +): string { + const promiseRef = [requestEvent.op, requestEvent.reqId].join('_'); + return promiseRef; +} diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 3240613..025d53b 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -1,482 +1,51 @@ /* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ /* eslint-disable max-len */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { EventEmitter } from 'events'; import WebSocket from 'isomorphic-ws'; -import { ContractClient } from './contract-client'; -import { InverseClient } from './inverse-client'; -import { LinearClient } from './linear-client'; -import { RestClientV5 } from './rest-client-v5'; -import { SpotClient } from './spot-client'; -import { SpotClientV3 } from './spot-client-v3'; import { - APIMarket, CategoryV5, - KlineInterval, - RESTClient, - WSClientConfigurableOptions, - WebsocketClientOptions, - WebsocketTopicSubscriptionConfirmationEvent, + MessageEventLike, WsKey, + WsMarket, WsTopic, } from './types'; -import { UnifiedMarginClient } from './unified-margin-client'; -import { USDCOptionClient } from './usdc-option-client'; -import { USDCPerpetualClient } from './usdc-perpetual-client'; import { - DefaultLogger, - PUBLIC_WS_KEYS, + APIID, + WSConnectedResult, WS_AUTH_ON_CONNECT_KEYS, WS_KEY_MAP, - WsConnectionStateEnum, - WsStore, + WsTopicRequest, getMaxTopicsPerSubscribeEvent, + getPromiseRefForWSAPIRequest, getWsKeyForTopic, getWsUrl, isPrivateWsTopic, isTopicSubscriptionConfirmation, isTopicSubscriptionSuccess, + isWSAPIResponse, isWsPong, neverGuard, - safeTerminateWs, - serializeParams, } from './util'; import { signMessage } from './util/node-support'; +import { BaseWebsocketClient, EmittableEvent } from './util/BaseWSClient'; +import { + WSAPIRequest, + WsAPIOperationResponseMap, + WsAPITopicRequestParamMap, + WsAPIWsKeyTopicMap, + WsOperation, + WsRequestOperationBybit, +} from './types/websockets/ws-api'; -const loggerCategory = { category: 'bybit-ws' }; - -export type WsClientEvent = - | 'open' - | 'update' - | 'close' - | 'error' - | 'reconnect' - | 'reconnected' - | 'response'; - -interface WebsocketClientEvents { - /** Connection opened. If this connection was previously opened and reconnected, expect the reconnected event instead */ - open: (evt: { wsKey: WsKey; event: any }) => void; - /** Reconnecting a dropped connection */ - reconnect: (evt: { wsKey: WsKey; event: any }) => void; - /** Successfully reconnected a connection that dropped */ - reconnected: (evt: { wsKey: WsKey; event: any }) => void; - /** Connection closed */ - close: (evt: { wsKey: WsKey; event: any }) => void; - /** Received reply to websocket command (e.g. after subscribing to topics) */ - response: (response: any) => void; - /** Received data for topic */ - update: (response: any) => void; - /** Exception from ws client OR custom listeners */ - error: (response: any) => void; -} - -type TopicsPendingSubscriptionsResolver = () => void; -type TopicsPendingSubscriptionsRejector = (reason: string) => void; - -interface TopicsPendingSubscriptions { - wsKey: string; - failedTopicsSubscriptions: Set; - pendingTopicsSubscriptions: Set; - resolver: TopicsPendingSubscriptionsResolver; - rejector: TopicsPendingSubscriptionsRejector; -} - -// Type safety for on and emit handlers: https://stackoverflow.com/a/61609010/880837 -export declare interface WebsocketClient { - on( - event: U, - listener: WebsocketClientEvents[U], - ): this; - - emit( - event: U, - ...args: Parameters - ): boolean; -} - -// eslint-disable-next-line no-redeclare -export class WebsocketClient extends EventEmitter { - private logger: typeof DefaultLogger; - - private restClient?: RESTClient; - - private options: WebsocketClientOptions; - - private wsStore: WsStore; - - private pendingTopicsSubscriptions: TopicsPendingSubscriptions[] = []; - - constructor( - options: WSClientConfigurableOptions, - logger?: typeof DefaultLogger, - ) { - super(); - - this.logger = logger || DefaultLogger; - this.wsStore = new WsStore(this.logger); - - this.options = { - testnet: false, - pongTimeout: 1000, - pingInterval: 10000, - reconnectTimeout: 500, - recvWindow: 5000, - fetchTimeOffsetBeforeAuth: false, - ...options, - }; - this.options.restOptions = { - ...this.options.restOptions, - testnet: this.options.testnet, - }; - - this.prepareRESTClient(); - - // add default error handling so this doesn't crash node (if the user didn't set a handler) - // eslint-disable-next-line @typescript-eslint/no-empty-function - this.on('error', () => {}); - } - - /** Get the WsStore that tracks websockets & topics */ - public getWsStore(): WsStore { - return this.wsStore; - } - - public isTestnet(): boolean { - return this.options.testnet === true; - } - - /** - * Subscribe to V5 topics & track/persist them. - * @param wsTopics - topic or list of topics - * @param category - the API category this topic is for (e.g. "linear"). The value is only important when connecting to public topics and will be ignored for private topics. - * @param isPrivateTopic - optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) - */ - public subscribeV5( - wsTopics: WsTopic[] | WsTopic, - category: CategoryV5, - isPrivateTopic?: boolean, - ) { - const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; - - return new Promise((resolver, rejector) => { - topics.forEach((topic) => { - const wsKey = getWsKeyForTopic( - this.options.market, - topic, - isPrivateTopic, - category, - ); - - // Persist topic for reconnects - this.wsStore.addTopic(wsKey, topic); - this.upsertPendingTopicsSubscriptions(wsKey, topic, resolver, rejector); - - // if connected, send subscription request - if ( - this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED) - ) { - return this.requestSubscribeTopics(wsKey, [topic]); - } - - // start connection process if it hasn't yet begun. Topics are automatically subscribed to on-connect - if ( - !this.wsStore.isConnectionState( - wsKey, - WsConnectionStateEnum.CONNECTING, - ) && - !this.wsStore.isConnectionState( - wsKey, - WsConnectionStateEnum.RECONNECTING, - ) - ) { - return this.connect(wsKey); - } - }); - }); - } - - /** - * Subscribe to V1-V3 topics & track/persist them. - * - * Note: for public V5 topics use the `subscribeV5()` method. - * - * Topics will be automatically resubscribed to if the connection resets/drops/reconnects. - * @param wsTopics - topic or list of topics - * @param isPrivateTopic optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) - */ - public subscribe( - wsTopics: WsTopic[] | WsTopic, - isPrivateTopic?: boolean, - ): Promise { - const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; - if (this.options.market === 'v5') { - topics.forEach((topic) => { - if (!isPrivateWsTopic(topic)) { - throw new Error( - 'For public "v5" websocket topics, use the subscribeV5() method & provide the category parameter', - ); - } - }); - } - - return new Promise((resolver, rejector) => { - topics.forEach((topic) => { - const wsKey = getWsKeyForTopic( - this.options.market, - topic, - isPrivateTopic, - ); - - // Persist topic for reconnects - this.wsStore.addTopic(wsKey, topic); - this.upsertPendingTopicsSubscriptions(wsKey, topic, resolver, rejector); - - // if connected, send subscription request - if ( - this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED) - ) { - return this.requestSubscribeTopics(wsKey, [topic]); - } - - // start connection process if it hasn't yet begun. Topics are automatically subscribed to on-connect - if ( - !this.wsStore.isConnectionState( - wsKey, - WsConnectionStateEnum.CONNECTING, - ) && - !this.wsStore.isConnectionState( - wsKey, - WsConnectionStateEnum.RECONNECTING, - ) - ) { - return this.connect(wsKey); - } - }); - }); - } - - private upsertPendingTopicsSubscriptions( - wsKey: string, - topic: string, - resolver: TopicsPendingSubscriptionsResolver, - rejector: TopicsPendingSubscriptionsRejector, - ) { - const existingWsKeyPendingSubscriptions = - this.pendingTopicsSubscriptions.find((s) => s.wsKey === wsKey); - if (!existingWsKeyPendingSubscriptions) { - this.pendingTopicsSubscriptions.push({ - wsKey, - resolver, - rejector, - failedTopicsSubscriptions: new Set(), - pendingTopicsSubscriptions: new Set([topic]), - }); - return; - } - - existingWsKeyPendingSubscriptions.pendingTopicsSubscriptions.add(topic); - } - - /** - * Unsubscribe from V5 topics & remove them from memory. They won't be re-subscribed to if the connection reconnects. - * @param wsTopics - topic or list of topics - * @param category - the API category this topic is for (e.g. "linear"). The value is only important when connecting to public topics and will be ignored for private topics. - * @param isPrivateTopic - optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) - */ - public unsubscribeV5( - wsTopics: WsTopic[] | WsTopic, - category: CategoryV5, - isPrivateTopic?: boolean, - ) { - const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; - topics.forEach((topic) => { - const wsKey = getWsKeyForTopic( - this.options.market, - topic, - isPrivateTopic, - category, - ); - - // Remove topic from persistence for reconnects - this.wsStore.deleteTopic(wsKey, topic); - this.removeTopicPendingSubscription(wsKey, topic); - - // unsubscribe request only necessary if active connection exists - if ( - this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED) - ) { - this.requestUnsubscribeTopics(wsKey, [topic]); - } - }); - } - - private removeTopicPendingSubscription(wsKey: string, topic: string) { - const existingWsKeyPendingSubscriptions = - this.pendingTopicsSubscriptions.find((s) => s.wsKey === wsKey); - if (existingWsKeyPendingSubscriptions) { - existingWsKeyPendingSubscriptions.pendingTopicsSubscriptions.delete( - topic, - ); - if (!existingWsKeyPendingSubscriptions.pendingTopicsSubscriptions.size) { - this.pendingTopicsSubscriptions = - this.pendingTopicsSubscriptions.filter((s) => s.wsKey !== wsKey); - } - } - } - - private clearTopicsPendingSubscriptions(wsKey: string) { - this.pendingTopicsSubscriptions = this.pendingTopicsSubscriptions.filter( - (s) => s.wsKey !== wsKey, - ); - } - - /** - * Unsubscribe from V1-V3 topics & remove them from memory. They won't be re-subscribed to if the connection reconnects. - * - * Note: For public V5 topics, use `unsubscribeV5()` instead! - * - * @param wsTopics topic or list of topics - * @param isPrivateTopic optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) - */ - public unsubscribe(wsTopics: WsTopic[] | WsTopic, isPrivateTopic?: boolean) { - const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; - if (this.options.market === 'v5') { - topics.forEach((topic) => { - if (!isPrivateWsTopic(topic)) { - throw new Error( - 'For public "v5" websocket topics, use the unsubscribeV5() method & provide the category parameter', - ); - } - }); - } - - topics.forEach((topic) => { - const wsKey = getWsKeyForTopic( - this.options.market, - topic, - isPrivateTopic, - ); - - // Remove topic from persistence for reconnects - this.wsStore.deleteTopic(wsKey, topic); - this.removeTopicPendingSubscription(wsKey, topic); - - // unsubscribe request only necessary if active connection exists - if ( - this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED) - ) { - this.requestUnsubscribeTopics(wsKey, [topic]); - } - }); - } - - /** - * @private Only used if we fetch exchange time before attempting auth. Disabled by default. - * I've removed this for ftx and it's working great, tempted to remove this here - */ - private prepareRESTClient(): void { - switch (this.options.market) { - case 'inverse': { - this.restClient = new InverseClient( - this.options.restOptions, - this.options.requestOptions, - ); - break; - } - case 'linear': { - this.restClient = new LinearClient( - this.options.restOptions, - this.options.requestOptions, - ); - break; - } - case 'spot': { - this.restClient = new SpotClient( - this.options.restOptions, - this.options.requestOptions, - ); - this.connectPublic(); - break; - } - case 'spotv3': { - this.restClient = new SpotClientV3( - this.options.restOptions, - this.options.requestOptions, - ); - break; - } - case 'usdcOption': { - this.restClient = new USDCOptionClient( - this.options.restOptions, - this.options.requestOptions, - ); - break; - } - case 'usdcPerp': { - this.restClient = new USDCPerpetualClient( - this.options.restOptions, - this.options.requestOptions, - ); - break; - } - case 'unifiedOption': - case 'unifiedPerp': { - this.restClient = new UnifiedMarginClient( - this.options.restOptions, - this.options.requestOptions, - ); - break; - } - case 'contractInverse': - case 'contractUSDT': { - this.restClient = new ContractClient( - this.options.restOptions, - this.options.requestOptions, - ); - break; - } - case 'v5': { - this.restClient = new RestClientV5( - this.options.restOptions, - this.options.requestOptions, - ); - break; - } - default: { - throw neverGuard( - this.options.market, - 'prepareRESTClient(): Unhandled market', - ); - } - } - } - - public close(wsKey: WsKey, force?: boolean) { - this.logger.info('Closing connection', { ...loggerCategory, wsKey }); - this.setWsState(wsKey, WsConnectionStateEnum.CLOSING); - this.clearTimers(wsKey); - - const ws = this.getWs(wsKey); - ws?.close(); - if (force) { - safeTerminateWs(ws); - } - } - - public closeAll(force?: boolean) { - const keys = this.wsStore.getKeys(); - this.logger.info(`Closing all ws connections: ${keys}`); - keys.forEach((key) => { - this.close(key, force); - }); - } +const WS_LOGGER_CATEGORY = { category: 'bybit-ws' }; +// export class WebsocketClient extends EventEmitter { +export class WebsocketClient extends BaseWebsocketClient { /** * Request connection of all dependent (public & private) websockets, instead of waiting for automatic connection by library */ - public connectAll(): Promise[] { + public connectAll(): Promise[] { switch (this.options.market) { case 'inverse': { // only one for inverse @@ -505,6 +74,15 @@ export class WebsocketClient extends EventEmitter { public connectPublic(): Promise[] { switch (this.options.market) { + case 'v5': + default: { + return [ + this.connect(WS_KEY_MAP.v5SpotPublic), + this.connect(WS_KEY_MAP.v5LinearPublic), + this.connect(WS_KEY_MAP.v5InversePublic), + this.connect(WS_KEY_MAP.v5OptionPublic), + ]; + } case 'inverse': { return [this.connect(WS_KEY_MAP.inverse)]; } @@ -536,25 +114,15 @@ export class WebsocketClient extends EventEmitter { return [this.connect(WS_KEY_MAP.contractUSDTPublic)]; case 'contractInverse': return [this.connect(WS_KEY_MAP.contractInversePublic)]; - case 'v5': { - return [ - this.connect(WS_KEY_MAP.v5SpotPublic), - this.connect(WS_KEY_MAP.v5LinearPublic), - this.connect(WS_KEY_MAP.v5InversePublic), - this.connect(WS_KEY_MAP.v5OptionPublic), - ]; - } - default: { - throw neverGuard( - this.options.market, - 'connectPublic(): Unhandled market', - ); - } } } public connectPrivate(): Promise { switch (this.options.market) { + case 'v5': + default: { + return this.connect(WS_KEY_MAP.v5Private); + } case 'inverse': { return this.connect(WS_KEY_MAP.inverse); } @@ -581,134 +149,275 @@ export class WebsocketClient extends EventEmitter { return this.connect(WS_KEY_MAP.contractUSDTPrivate); case 'contractInverse': return this.connect(WS_KEY_MAP.contractInversePrivate); - case 'v5': { - return this.connect(WS_KEY_MAP.v5Private); - } - default: { - throw neverGuard( - this.options.market, - 'connectPrivate(): Unhandled market', - ); - } } } - private async connect(wsKey: WsKey): Promise { - try { - if (this.wsStore.isWsOpen(wsKey)) { - this.logger.error( - 'Refused to connect to ws with existing active connection', - { ...loggerCategory, wsKey }, - ); - return this.wsStore.getWs(wsKey); - } - - if ( - this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTING) - ) { - this.logger.error( - 'Refused to connect to ws, connection attempt already active', - { ...loggerCategory, wsKey }, - ); - return; - } - - if ( - !this.wsStore.getConnectionState(wsKey) || - this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.INITIAL) - ) { - this.setWsState(wsKey, WsConnectionStateEnum.CONNECTING); - } - - const authParams = await this.getAuthParams(wsKey); - const url = getWsUrl(wsKey, this.options, this.logger); - const ws = this.connectToWsUrl(url + authParams, wsKey); - - return this.wsStore.setWs(wsKey, ws); - } catch (err) { - this.parseWsError('Connection failed', err, wsKey); - this.reconnectWithDelay(wsKey, this.options.reconnectTimeout); - } - } - - private parseWsError(context: string, error: any, wsKey: WsKey) { - if (!error.message) { - this.logger.error(`${context} due to unexpected error: `, error); - this.emit('error', error); + /** + * Request subscription to one or more topics. Pass topics as either an array of strings, or array of objects (if the topic has parameters). + * Objects should be formatted as {topic: string, params: object}. + * + * - Subscriptions are automatically routed to the correct websocket connection. + * - Authentication/connection is automatic. + * - Resubscribe after network issues is automatic. + * + * Call `unsubscribe(topics)` to remove topics + */ + public subscribe( + requests: + | (WsTopicRequest | WsTopic) + | (WsTopicRequest | WsTopic)[], + wsKey: WsKey, + ) { + if (!Array.isArray(requests)) { + this.subscribeTopicsForWsKey([requests], wsKey); return; } - switch (error.message) { - case 'Unexpected server response: 401': - this.logger.error(`${context} due to 401 authorization failure.`, { - ...loggerCategory, - wsKey, - }); - break; + if (requests.length) { + this.subscribeTopicsForWsKey(requests, wsKey); + } + } - default: - if ( - this.wsStore.getConnectionState(wsKey) !== - WsConnectionStateEnum.CLOSING - ) { - this.logger.error( - `${context} due to unexpected response error: "${ - error?.msg || error?.message || error - }"`, - { ...loggerCategory, wsKey, error }, - ); - this.executeReconnectableClose(wsKey, 'unhandled onWsError'); - } else { - this.logger.info( - `${wsKey} socket forcefully closed. Will not reconnect.`, + /** + * Unsubscribe from one or more topics. Similar to subscribe() but in reverse. + * + * - Requests are automatically routed to the correct websocket connection. + * - These topics will be removed from the topic cache, so they won't be subscribed to again. + */ + public unsubscribe( + requests: + | (WsTopicRequest | WsTopic) + | (WsTopicRequest | WsTopic)[], + wsKey: WsKey, + ) { + if (!Array.isArray(requests)) { + this.unsubscribeTopicsForWsKey([requests], wsKey); + return; + } + + if (requests.length) { + this.unsubscribeTopicsForWsKey(requests, wsKey); + } + } + + /******* + * + * + * + * + * OLD WS CLIENT BELOW + * + * + * + * + * + * + */ + + /** + * + * Subscribe to V5 topics & track/persist them. + * @param wsTopics - topic or list of topics + * @param category - the API category this topic is for (e.g. "linear"). The value is only important when connecting to public topics and will be ignored for private topics. + * @param isPrivateTopic - optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) + */ + public subscribeV5( + wsTopics: WsTopic[] | WsTopic, + category: CategoryV5, + isPrivateTopic?: boolean, + ) { + // TODO: sort into WS key then bulk sub per wskey + const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; + + return new Promise((resolver, rejector) => { + topics.forEach((topic) => { + const wsKey = getWsKeyForTopic( + this.options.market, + topic, + isPrivateTopic, + category, + ); + + // TODO: move this to base client + this.upsertPendingTopicsSubscriptions(wsKey, topic, resolver, rejector); + + const wsRequest: WsTopicRequest = { + topic: topic, + category: category, + }; + + // Persist topic for reconnects + this.subscribeTopicsForWsKey([wsRequest], wsKey); + }); + }); + } + + /** + * Unsubscribe from V5 topics & remove them from memory. They won't be re-subscribed to if the connection reconnects. + * @param wsTopics - topic or list of topics + * @param category - the API category this topic is for (e.g. "linear"). The value is only important when connecting to public topics and will be ignored for private topics. + * @param isPrivateTopic - optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) + */ + public unsubscribeV5( + wsTopics: WsTopic[] | WsTopic, + category: CategoryV5, + isPrivateTopic?: boolean, + ) { + // TODO: sort into WS key then bulk sub per wskey + const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; + + topics.forEach((topic) => { + const wsKey = getWsKeyForTopic( + this.options.market, + topic, + isPrivateTopic, + category, + ); + + const wsRequest: WsTopicRequest = { + topic: topic, + category: category, + }; + + this.removeTopicPendingSubscription(wsKey, topic); + + // Remove topic from persistence for reconnects and unsubscribe + this.unsubscribeTopicsForWsKey([wsRequest], wsKey); + }); + } + + /** + * Subscribe to V1-V3 topics & track/persist them. + * + * Note: for public V5 topics use the `subscribeV5()` method. + * + * Topics will be automatically resubscribed to if the connection resets/drops/reconnects. + * @param wsTopics - topic or list of topics + * @param isPrivateTopic optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) + */ + public subscribeV3( + wsTopics: WsTopic[] | WsTopic, + isPrivateTopic?: boolean, + ): Promise { + const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; + if (this.options.market === 'v5') { + topics.forEach((topic) => { + if (!isPrivateWsTopic(topic)) { + throw new Error( + 'For public "v5" websocket topics, use the subscribeV5() method & provide the category parameter', ); } - break; + }); } - this.emit('error', error); + + return new Promise((resolver, rejector) => { + topics.forEach((topic) => { + const wsKey = getWsKeyForTopic( + this.options.market, + topic, + isPrivateTopic, + ); + + // TODO: move to base client + this.upsertPendingTopicsSubscriptions(wsKey, topic, resolver, rejector); + + const wsRequest: WsTopicRequest = { + topic: topic, + }; + + // Persist topic for reconnects + this.subscribeTopicsForWsKey([wsRequest], wsKey); + }); + }); + } + + /** + * Unsubscribe from V1-V3 topics & remove them from memory. They won't be re-subscribed to if the connection reconnects. + * + * Note: For public V5 topics, use `unsubscribeV5()` instead! + * + * @param wsTopics topic or list of topics + * @param isPrivateTopic optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) + */ + public unsubscribeV3( + wsTopics: WsTopic[] | WsTopic, + isPrivateTopic?: boolean, + ) { + const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; + if (this.options.market === 'v5') { + topics.forEach((topic) => { + if (!isPrivateWsTopic(topic)) { + throw new Error( + 'For public "v5" websocket topics, use the unsubscribeV5() method & provide the category parameter', + ); + } + }); + } + + topics.forEach((topic) => { + const wsKey = getWsKeyForTopic( + this.options.market, + topic, + isPrivateTopic, + ); + + // TODO: move to base client + this.removeTopicPendingSubscription(wsKey, topic); + + const wsRequest: WsTopicRequest = { + topic: topic, + }; + + // Persist topic for reconnects + this.unsubscribeTopicsForWsKey([wsRequest], wsKey); + }); + } + + /** + * + * + * Internal methods - not intended for public use + * + * + */ + + /** + * @returns The WS URL to connect to for this WS key + */ + protected async getWsUrl(wsKey: WsKey): Promise { + const wsBaseURL = getWsUrl(wsKey, this.options, this.logger); + + // If auth is needed for this wsKey URL, this returns a suffix + const authParams = await this.getWsAuthURLSuffix(wsKey); + if (!authParams) { + return wsBaseURL; + } + + return wsBaseURL + '?' + authParams; } /** * Return params required to make authorized request */ - private async getAuthParams(wsKey: WsKey): Promise { - if (PUBLIC_WS_KEYS.includes(wsKey)) { - this.logger.debug('Starting public only websocket client.', { - ...loggerCategory, - wsKey, - }); - return ''; - } - - try { - const { signature, expiresAt } = await this.getWsAuthSignature(wsKey); - - const authParams = { - api_key: this.options.key, - expires: expiresAt, - signature, - }; - - return '?' + serializeParams(authParams); - } catch (e) { - this.logger.error(e, { ...loggerCategory, wsKey }); - return ''; - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + private async getWsAuthURLSuffix(wsKey: WsKey): Promise { + return ''; } - private async sendAuthRequest(wsKey: WsKey): Promise { + protected async getWsAuthRequestEvent(wsKey: WsKey): Promise { try { const { signature, expiresAt } = await this.getWsAuthSignature(wsKey); - const request = { + const request: WsRequestOperationBybit = { op: 'auth', - args: [this.options.key, expiresAt, signature], + args: [this.options.key!, expiresAt, signature], req_id: `${wsKey}-auth`, }; - return this.tryWsSend(wsKey, JSON.stringify(request)); + return request; } catch (e) { - this.logger.error(e, { ...loggerCategory, wsKey }); + this.logger.error(e, { ...WS_LOGGER_CATEGORY, wsKey }); + throw e; } } @@ -718,25 +427,21 @@ export class WebsocketClient extends EventEmitter { const { key, secret } = this.options; if (!key || !secret) { - this.logger.warning( + this.logger.error( 'Cannot authenticate websocket, either api or private keys missing.', - { ...loggerCategory, wsKey }, + { ...WS_LOGGER_CATEGORY, wsKey }, ); throw new Error('Cannot auth - missing api or secret in config'); } - this.logger.debug("Getting auth'd request params", { - ...loggerCategory, + this.logger.trace("Getting auth'd request params", { + ...WS_LOGGER_CATEGORY, wsKey, }); - const timeOffset = this.options.fetchTimeOffsetBeforeAuth - ? (await this.restClient?.fetchTimeOffset()) || 0 - : 0; - const recvWindow = this.options.recvWindow || 5000; - const signatureExpiresAt = Date.now() + timeOffset + recvWindow; + const signatureExpiresAt = Date.now() + this.getTimeOffsetMs() + recvWindow; const signature = await signMessage( 'GET/realtime' + signatureExpiresAt, @@ -749,516 +454,527 @@ export class WebsocketClient extends EventEmitter { }; } - private reconnectWithDelay(wsKey: WsKey, connectionDelayMs: number) { - this.clearTimers(wsKey); - - if ( - this.wsStore.getConnectionState(wsKey) !== - WsConnectionStateEnum.CONNECTING - ) { - this.setWsState(wsKey, WsConnectionStateEnum.RECONNECTING); - } - - if (this.wsStore.get(wsKey)?.activeReconnectTimer) { - this.clearReconnectTimer(wsKey); - } - - this.wsStore.get(wsKey, true).activeReconnectTimer = setTimeout(() => { - this.logger.info('Reconnecting to websocket', { - ...loggerCategory, - wsKey, - }); - this.clearReconnectTimer(wsKey); - this.connect(wsKey); - }, connectionDelayMs); + private async signWSAPIRequest( + requestEvent: WSAPIRequest, + ): Promise> { + // Not needed for Bybit. Auth happens only on connection open, automatically. + return requestEvent; } - private ping(wsKey: WsKey) { - if (this.wsStore.get(wsKey, true).activePongTimer) { - return; - } - - this.clearPongTimer(wsKey); - - this.logger.silly('Sending ping', { ...loggerCategory, wsKey }); + protected sendPingEvent(wsKey: WsKey) { this.tryWsSend(wsKey, JSON.stringify({ op: 'ping' })); + } - this.wsStore.get(wsKey, true).activePongTimer = setTimeout( - () => this.executeReconnectableClose(wsKey, 'Pong timeout'), - this.options.pongTimeout, - ); + protected sendPongEvent(wsKey: WsKey) { + this.tryWsSend(wsKey, JSON.stringify({ op: 'pong' })); + } + + /** Force subscription requests to be sent in smaller batches, if a number is returned */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected getMaxTopicsPerSubscribeEvent(wsKey: WsKey): number | null { + return getMaxTopicsPerSubscribeEvent(this.options.market, wsKey); } /** - * Closes a connection, if it's even open. If open, this will trigger a reconnect asynchronously. - * If closed, trigger a reconnect immediately + * @returns one or more correctly structured request events for performing a operations over WS. This can vary per exchange spec. */ - private executeReconnectableClose(wsKey: WsKey, reason: string) { - this.logger.info(`${reason} - closing socket to reconnect`, { - ...loggerCategory, - wsKey, - reason, - }); + protected async getWsRequestEvents( + market: WsMarket, + operation: WsOperation, + requests: WsTopicRequest[], + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + wsKey: WsKey, + ): Promise[]> { + const wsRequestEvents: WsRequestOperationBybit[] = []; + const wsRequestBuildingErrors: unknown[] = []; - const wasOpen = this.wsStore.isWsOpen(wsKey); + switch (market) { + case 'all': { + const wsEvent: WsRequestOperationBybit = { + req_id: this.getNewRequestId(), + op: operation, + args: requests.map((r) => r.topic), + }; - this.clearPingTimer(wsKey); - this.clearPongTimer(wsKey); - - const ws = this.getWs(wsKey); - - if (ws) { - ws.close(); - safeTerminateWs(ws); + wsRequestEvents.push({ + ...wsEvent, + }); + break; + } + default: { + throw neverGuard(market, `Unhandled market "${market}"`); + } } - if (!wasOpen) { - this.logger.info( - `${reason} - socket already closed - trigger immediate reconnect`, + if (wsRequestBuildingErrors.length) { + const label = + wsRequestBuildingErrors.length === requests.length ? 'all' : 'some'; + this.logger.error( + `Failed to build/send ${wsRequestBuildingErrors.length} event(s) for ${label} WS requests due to exceptions`, { - ...loggerCategory, - wsKey, - reason, + ...WS_LOGGER_CATEGORY, + wsRequestBuildingErrors, + wsRequestBuildingErrorsStringified: JSON.stringify( + wsRequestBuildingErrors, + null, + 2, + ), }, ); - this.reconnectWithDelay(wsKey, this.options.reconnectTimeout); } + + return wsRequestEvents; } - private clearTimers(wsKey: WsKey) { - this.clearPingTimer(wsKey); - this.clearPongTimer(wsKey); - this.clearReconnectTimer(wsKey); + protected getPrivateWSKeys(): WsKey[] { + return [ + WS_KEY_MAP.linearPrivate, + WS_KEY_MAP.spotPrivate, + WS_KEY_MAP.spotV3Private, + WS_KEY_MAP.usdcOptionPrivate, + WS_KEY_MAP.usdcPerpPrivate, + WS_KEY_MAP.unifiedPrivate, + WS_KEY_MAP.contractUSDTPrivate, + WS_KEY_MAP.contractInversePrivate, + WS_KEY_MAP.v5Private, + ]; } - // Send a ping at intervals - private clearPingTimer(wsKey: WsKey) { - const wsState = this.wsStore.get(wsKey); - if (wsState?.activePingTimer) { - clearInterval(wsState.activePingTimer); - wsState.activePingTimer = undefined; - } - } - - // Expect a pong within a time limit - private clearPongTimer(wsKey: WsKey) { - const wsState = this.wsStore.get(wsKey); - if (wsState?.activePongTimer) { - clearTimeout(wsState.activePongTimer); - wsState.activePongTimer = undefined; - } - } - - private clearReconnectTimer(wsKey: WsKey) { - const wsState = this.wsStore.get(wsKey); - if (wsState?.activeReconnectTimer) { - clearTimeout(wsState.activeReconnectTimer); - wsState.activeReconnectTimer = undefined; - } + protected isAuthOnConnectWsKey(wsKey: WsKey): boolean { + return WS_AUTH_ON_CONNECT_KEYS.includes(wsKey); } /** - * @private Use the `subscribe(topics)` method to subscribe to topics. Send WS message to subscribe to topics. + * Determines if a topic is for a private channel, using a hardcoded list of strings */ - private requestSubscribeTopics(wsKey: WsKey, topics: string[]) { - if (!topics.length) { - return; + protected isPrivateTopicRequest( + request: WsTopicRequest, + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + wsKey: WsKey, + ): boolean { + const topicName = request?.topic?.toLowerCase(); + if (!topicName) { + return false; } - const maxTopicsPerEvent = getMaxTopicsPerSubscribeEvent( - this.options.market, - wsKey, - ); - if (maxTopicsPerEvent && topics.length > maxTopicsPerEvent) { - this.logger.silly( - `Subscribing to topics in batches of ${maxTopicsPerEvent}`, - ); - for (let i = 0; i < topics.length; i += maxTopicsPerEvent) { - const batch = topics.slice(i, i + maxTopicsPerEvent); - this.logger.silly(`Subscribing to batch of ${batch.length}`); - this.requestSubscribeTopics(wsKey, batch); + return isPrivateWsTopic(topicName); + } + + protected isWsPing(msg: any): boolean { + if (!msg) { + return false; + } + + if (typeof msg?.data === 'string') { + if (msg.data.includes('op": "ping')) { + return true; } - this.logger.silly( - `Finished batch subscribing to ${topics.length} topics`, - ); - return; + + // console.log('isWsPing?', { + // data: msg.data, + // }); + return false; } - const wsMessage = JSON.stringify({ - req_id: topics.join(','), - op: 'subscribe', - args: topics, - }); + return false; + } - this.tryWsSend(wsKey, wsMessage); + protected isWsPong(msg: any): boolean { + if (!msg) { + return false; + } + + if (typeof msg?.data === 'string') { + // public ws connections + if (msg.data.includes('ret_msg":"pong')) { + return true; + } + + // private ws connections + if (msg.data.includes('op":"pong')) { + return true; + } + + // console.log('isWsPong?', { + // data: msg.data, + // }); + return false; + } + + if (msg.event?.ret_msg === 'pong') { + return true; + } + + return msg?.pong || isWsPong(msg); } /** - * @private Use the `unsubscribe(topics)` method to unsubscribe from topics. Send WS message to unsubscribe from topics. + * Abstraction called to sort ws events into emittable event types (response to a request, data update, etc) */ - private requestUnsubscribeTopics(wsKey: WsKey, topics: string[]) { - if (!topics.length) { - return; - } + protected resolveEmittableEvents( + wsKey: WsKey, + event: MessageEventLike, + ): EmittableEvent[] { + const results: EmittableEvent[] = []; - const maxTopicsPerEvent = getMaxTopicsPerSubscribeEvent( - this.options.market, - wsKey, - ); - if (maxTopicsPerEvent && topics.length > maxTopicsPerEvent) { - this.logger.silly( - `Unsubscribing to topics in batches of ${maxTopicsPerEvent}`, - ); - for (let i = 0; i < topics.length; i += maxTopicsPerEvent) { - const batch = topics.slice(i, i + maxTopicsPerEvent); - this.logger.silly(`Unsubscribing to batch of ${batch.length}`); - this.requestUnsubscribeTopics(wsKey, batch); - } - this.logger.silly( - `Finished batch unsubscribing to ${topics.length} topics`, - ); - return; - } - - const wsMessage = JSON.stringify({ - op: 'unsubscribe', - args: topics, - }); - - this.tryWsSend(wsKey, wsMessage); - } - - public tryWsSend(wsKey: WsKey, 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', - ); - } - 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, - }); - } - } - - private connectToWsUrl(url: string, wsKey: WsKey): WebSocket { - this.logger.silly(`Opening WS connection to URL: ${url}`, { - ...loggerCategory, - wsKey, - }); - - const agent = this.options.requestOptions?.agent; - const ws = new WebSocket(url, undefined, agent ? { agent } : undefined); - ws.onopen = (event) => this.onWsOpen(event, wsKey); - ws.onmessage = (event) => this.onWsMessage(event, wsKey); - ws.onerror = (event) => - this.parseWsError('Websocket onWsError', event, wsKey); - ws.onclose = (event) => this.onWsClose(event, wsKey); - - return ws; - } - - private async onWsOpen(event, wsKey: WsKey) { - if ( - this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTING) - ) { - this.logger.info('Websocket connected', { - ...loggerCategory, - wsKey, - testnet: this.isTestnet(), - market: this.options.market, - }); - this.emit('open', { wsKey, event }); - } else if ( - this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.RECONNECTING) - ) { - this.logger.info('Websocket reconnected', { ...loggerCategory, wsKey }); - this.emit('reconnected', { wsKey, event }); - } - - this.setWsState(wsKey, WsConnectionStateEnum.CONNECTED); - - // Some websockets require an auth packet to be sent after opening the connection - if (WS_AUTH_ON_CONNECT_KEYS.includes(wsKey)) { - this.logger.info('Sending auth request...'); - await this.sendAuthRequest(wsKey); - } - - // TODO: persistence not working yet for spot v1 topics - if (wsKey !== WS_KEY_MAP.spotPublic && wsKey !== WS_KEY_MAP.spotPrivate) { - const topics = [...this.wsStore.getTopics(wsKey)]; - this.logger.info('Subscribing to topics', { - ...loggerCategory, - wsKey, - topics, - }); - this.requestSubscribeTopics(wsKey, topics); - } - - this.wsStore.get(wsKey, true)!.activePingTimer = setInterval( - () => this.ping(wsKey), - this.options.pingInterval, - ); - } - - private onWsMessage(event, wsKey: WsKey) { - try { - // any message can clear the pong timer - wouldn't get a message if the ws dropped - this.clearPongTimer(wsKey); - - const msg = JSON.parse((event && event.data) || event); - // this.logger.silly('Received event', { - // ...loggerCategory, + const parsed = JSON.parse(event.data); + // this.logger.trace('resolveEmittableEvents', { + // ...WS_LOGGER_CATEGORY, // wsKey, - // msg: JSON.stringify(msg), + // parsed: JSON.stringify(parsed), // }); - if (isTopicSubscriptionConfirmation(msg)) { - this.updatePendingTopicSubscriptionStatus(wsKey, msg); + if (isTopicSubscriptionConfirmation(parsed)) { + const isTopicSubscriptionSuccessEvent = + isTopicSubscriptionSuccess(parsed); + this.updatePendingTopicSubscriptionStatus( + wsKey, + parsed, + isTopicSubscriptionSuccessEvent, + ); } - // TODO: cleanme - if (msg['success'] || msg?.pong || isWsPong(msg)) { - if (isWsPong(msg)) { - this.logger.silly('Received pong', { ...loggerCategory, wsKey }); - } else { - this.emit('response', { ...msg, wsKey }); + const EVENTS_AUTHENTICATED = ['auth']; + const EVENTS_RESPONSES = [ + 'subscribe', + 'unsubscribe', + 'COMMAND_RESP', + 'ping', + 'pong', + ]; + + const eventTopic = parsed?.topic; + const eventOperation = parsed?.op; + + // Messages for a subscribed topic all include the "topic" property + if (typeof eventTopic === 'string') { + results.push({ + eventType: 'update', + event: parsed, + }); + return results; + } + + // Messages that are a "reply" to a request/command (e.g. subscribe to these topics) typically include the "op" property + if (typeof eventOperation === 'string') { + // Failed request + if (parsed.success === false) { + results.push({ + eventType: 'exception', + event: parsed, + }); + return results; } - return; + + // These are r equest/reply pattern events (e.g. after subscribing to topics or authenticating) + if (EVENTS_RESPONSES.includes(eventOperation)) { + results.push({ + eventType: 'response', + event: parsed, + }); + return results; + } + + // Request/reply pattern for authentication success + if (EVENTS_AUTHENTICATED.includes(eventOperation)) { + results.push({ + eventType: 'authenticated', + event: parsed, + }); + return results; + } + + // WS API response + if (isWSAPIResponse(parsed)) { + const retCode = parsed.retCode; + const reqId = parsed.reqId; + + const isError = retCode !== 0; + + const promiseRef = [parsed.op, reqId].join('_'); + + // WS API Exception + if (isError) { + try { + this.getWsStore().rejectDeferredPromise( + wsKey, + promiseRef, + { + wsKey, + ...parsed, + }, + true, + ); + } catch (e) { + this.logger.error('Exception trying to reject WSAPI promise', { + wsKey, + promiseRef, + parsedEvent: parsed, + }); + } + + results.push({ + eventType: 'exception', + event: parsed, + }); + return results; + } + + // WS API Success + try { + this.getWsStore().resolveDeferredPromise( + wsKey, + promiseRef, + { + wsKey, + ...parsed, + }, + true, + ); + } catch (e) { + this.logger.error('Exception trying to resolve WSAPI promise', { + wsKey, + promiseRef, + parsedEvent: parsed, + }); + } + + results.push({ + eventType: 'response', + event: parsed, + }); + + return results; + } + + // const wsAPIExample = { + // reqId: '1', + // retCode: 0, + // retMsg: 'OK', + // op: 'order.create', + // data: { + // orderId: '454c62ab-cb89-4f19-b70e-6123d3a53817', + // orderLinkId: '', + // }, + // header: { + // 'X-Bapi-Limit': '10', + // 'X-Bapi-Limit-Status': '9', + // 'X-Bapi-Limit-Reset-Timestamp': '1737041109260', + // Traceid: '7e34e1105f093eff75dd7de0f1a59771', + // Timenow: '1737041109263', + // }, + // connId: 'ctb9l5v88smdae1fivmg-5esl', + // }; + + this.logger.error( + `!! Unhandled string operation type "${eventOperation}". Defaulting to "update" channel...`, + parsed, + ); + } else { + this.logger.error( + `!! Unhandled non-string event type "${eventOperation}". Defaulting to "update" channel...`, + parsed, + ); } - if (msg['finalFragment']) { - return this.emit('response', { ...msg, wsKey }); - } - if (msg?.topic) { - return this.emit('update', { ...msg, wsKey }); - } + // TODO: WS API + // const eventChannel = parsed.op; + // const requestId = parsed?.request_id; + // const promiseRef = [eventChannel, requestId].join('_'); + // if (eventType === 'api') { + // const isError = eventStatusCode !== '200'; - if ( - // spot v1 - msg?.code || - // spot v3 - msg?.type === 'error' || - // usdc options - msg?.success === false - ) { - return this.emit('error', { ...msg, wsKey }); - } + // // WS API Exception + // if (isError) { + // try { + // this.getWsStore().rejectDeferredPromise( + // wsKey, + // promiseRef, + // { + // wsKey, + // ...parsed, + // }, + // true, + // ); + // } catch (e) { + // this.logger.error('Exception trying to reject WSAPI promise', { + // wsKey, + // promiseRef, + // parsedEvent: parsed, + // }); + // } - this.logger.warning('Unhandled/unrecognised ws event message', { - ...loggerCategory, - message: msg, - event, - wsKey, + // results.push({ + // eventType: 'exception', + // event: parsed, + // }); + // return results; + // } + + // // WS API Success + // try { + // this.getWsStore().resolveDeferredPromise( + // wsKey, + // promiseRef, + // { + // wsKey, + // ...parsed, + // }, + // true, + // ); + // } catch (e) { + // this.logger.error('Exception trying to resolve WSAPI promise', { + // wsKey, + // promiseRef, + // parsedEvent: parsed, + // }); + // } + + // if (eventChannel.includes('.login')) { + // results.push({ + // eventType: 'authenticated', + // event: { + // ...parsed, + // isWSAPI: true, + // WSAPIAuthChannel: eventChannel, + // }, + // }); + // } + + // results.push({ + // eventType: 'response', + // event: parsed, + // }); + // return results; + // } + + // In case of catastrophic failure, fallback to noisy emit update + results.push({ + eventType: 'update', + event: parsed, }); } catch (e) { - this.logger.error('Failed to parse ws event message', { - ...loggerCategory, - error: e, - event, - wsKey, + results.push({ + event: { + message: 'Failed to parse event data due to exception', + exception: e, + eventData: event.data, + }, + eventType: 'exception', + }); + + this.logger.error('Failed to parse event data due to exception: ', { + exception: e, + eventData: event.data, }); } + + return results; } - private updatePendingTopicSubscriptionStatus( - wsKey: string, - msg: WebsocketTopicSubscriptionConfirmationEvent, - ) { - const requestsIds = msg.req_id as string; - const pendingTopicsSubscriptions = this.pendingTopicsSubscriptions.find( - (s) => s.wsKey === wsKey, - ); + /** + * + * + * + * WS API Methods - similar to the REST API, but via WebSockets + * + * + * + */ - if (!pendingTopicsSubscriptions) return; + /** + * Send a Websocket API event on a connection. Returns a promise that resolves on reply. + * + * Returned promise is rejected if an exception is detected in the reply OR the connection disconnects for any reason (even if automatic reconnect will happen). + * + * After a fresh connection, you should always send a login request first. + * + * If you authenticated once and you're reconnected later (e.g. connection temporarily lost), the SDK will by default automatically: + * - Detect you were authenticated to the WS API before + * - Try to re-authenticate (up to 5 times, in case something (bad timestamp) goes wrong) + * - If it succeeds, it will emit the 'authenticated' event. + * - If it fails and gives up, it will emit an 'exception' event (type: 'wsapi.auth', reason: detailed text). + * + * You can turn off the automatic re-auth WS API logic using `reauthWSAPIOnReconnect: false` in the WSClient config. + * + * @param wsKey - The connection this event is for (e.g. "spotV4" | "perpFuturesUSDTV4" | "perpFuturesBTCV4" | "deliveryFuturesUSDTV4" | "deliveryFuturesBTCV4" | "optionsV4") + * @param channel - The channel this event is for (e.g. "spot.login" to authenticate) + * @param params - Any request parameters for the payload (contents of req_param in the docs). Signature generation is automatic, only send parameters such as order ID as per the docs. + * @returns Promise - tries to resolve with async WS API response. Rejects if disconnected or exception is seen in async WS API response + */ - const splitRequestsIds = requestsIds.split(','); - if (!isTopicSubscriptionSuccess(msg)) { - splitRequestsIds.forEach((req_id) => - pendingTopicsSubscriptions.failedTopicsSubscriptions.add(req_id), - ); - } + // This overload allows the caller to omit the 3rd param, if it isn't required (e.g. for the login call) + async sendWSAPIRequest< + TWSKey extends keyof WsAPIWsKeyTopicMap, + TWSOperation extends + WsAPIWsKeyTopicMap[TWSKey] = WsAPIWsKeyTopicMap[TWSKey], + TWSParams extends + WsAPITopicRequestParamMap[TWSOperation] = WsAPITopicRequestParamMap[TWSOperation], + TWSAPIResponse extends + | WsAPIOperationResponseMap[TWSOperation] + | object = WsAPIOperationResponseMap[TWSOperation], + >( + wsKey: TWSKey, + operation: TWSOperation, + ...params: TWSParams extends undefined ? [] : [TWSParams] + ): Promise; - splitRequestsIds.forEach((req_id) => { - this.removeTopicPendingSubscription(wsKey, req_id); + async sendWSAPIRequest< + TWSKey extends keyof WsAPIWsKeyTopicMap = keyof WsAPIWsKeyTopicMap, + TWSOperation extends + WsAPIWsKeyTopicMap[TWSKey] = WsAPIWsKeyTopicMap[TWSKey], + TWSParams extends + WsAPITopicRequestParamMap[TWSOperation] = WsAPITopicRequestParamMap[TWSOperation], + TWSAPIResponse extends + | WsAPIOperationResponseMap[TWSOperation] + | object = WsAPIOperationResponseMap[TWSOperation], + >( + wsKey: WsKey = WS_KEY_MAP.v5PrivateTrade, + operation: TWSOperation, + params: TWSParams, + ): Promise { + this.logger.trace(`sendWSAPIRequest(): assert "${wsKey}" is connected`); + await this.assertIsConnected(wsKey); + this.logger.trace('sendWSAPIRequest()->assertIsConnected() ok'); - if ( - !pendingTopicsSubscriptions.pendingTopicsSubscriptions.size && - !pendingTopicsSubscriptions.failedTopicsSubscriptions.size - ) { - // all topics have been subscribed successfully, so we can resolve the subscription request - pendingTopicsSubscriptions.resolver(); - this.clearTopicsPendingSubscriptions(wsKey); - } + await this.assertIsAuthenticated(wsKey); + this.logger.trace('sendWSAPIRequest()->assertIsAuthenticated() ok'); - if ( - !pendingTopicsSubscriptions.pendingTopicsSubscriptions.size && - pendingTopicsSubscriptions.failedTopicsSubscriptions.size - ) { - // not all topics have been subscribed successfully, so we reject the subscription request - // and let the caller handle the situation by providing the list of failed subscriptions requests - const failedSubscriptionsMessage = `(${[ - ...pendingTopicsSubscriptions.failedTopicsSubscriptions, - ].toString()}) failed to subscribe`; - pendingTopicsSubscriptions.rejector(failedSubscriptionsMessage); - this.clearTopicsPendingSubscriptions(wsKey); - } - }); - } - - private onWsClose(event, wsKey: WsKey) { - this.logger.info('Websocket connection closed', { - ...loggerCategory, - wsKey, - }); - - if ( - this.wsStore.getConnectionState(wsKey) !== WsConnectionStateEnum.CLOSING - ) { - this.reconnectWithDelay(wsKey, this.options.reconnectTimeout); - this.emit('reconnect', { wsKey, event }); - } else { - this.setWsState(wsKey, WsConnectionStateEnum.INITIAL); - this.emit('close', { wsKey, event }); - } - } - - private getWs(wsKey: WsKey) { - return this.wsStore.getWs(wsKey); - } - - private setWsState(wsKey: WsKey, state: WsConnectionStateEnum) { - this.wsStore.setConnectionState(wsKey, state); - } - - 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 ${market} topics`, - ); - } - - /** @deprecated use "market: 'spotv3" client */ - public subscribePublicSpotTrades(symbol: string, binary?: boolean) { - if (this.options.market !== 'spot') { - throw this.wrongMarketError('spot'); - } - - return this.tryWsSend( - WS_KEY_MAP.spotPublic, - JSON.stringify({ - topic: 'trade', - event: 'sub', - symbol, - params: { - binary: !!binary, - }, - }), - ); - } - - /** @deprecated use "market: 'spotv3" client */ - public subscribePublicSpotTradingPair(symbol: string, binary?: boolean) { - if (this.options.market !== 'spot') { - throw this.wrongMarketError('spot'); - } - - return this.tryWsSend( - WS_KEY_MAP.spotPublic, - JSON.stringify({ - symbol, - topic: 'realtimes', - event: 'sub', - params: { - binary: !!binary, - }, - }), - ); - } - - /** @deprecated use "market: 'spotv3" client */ - public subscribePublicSpotV1Kline( - symbol: string, - candleSize: KlineInterval, - binary?: boolean, - ) { - if (this.options.market !== 'spot') { - throw this.wrongMarketError('spot'); - } - - return this.tryWsSend( - WS_KEY_MAP.spotPublic, - 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}}'); - - /** @deprecated use "market: 'spotv3" client */ - public subscribePublicSpotOrderbook( - symbol: string, - depth: 'full' | 'merge' | 'delta', - dumpScale?: number, - binary?: boolean, - ) { - if (this.options.market !== 'spot') { - 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, + const requestEvent: WSAPIRequest = { + reqId: this.getNewRequestId(), + header: { + 'X-BAPI-RECV-WINDOW': `${this.options.recvWindow}`, + 'X-BAPI-TIMESTAMP': `${Date.now()}`, + Referer: APIID, }, + op: operation, + args: [params], }; - if (dumpScale) { - msg.params.dumpScale = dumpScale; - } - return this.tryWsSend(WS_KEY_MAP.spotPublic, JSON.stringify(msg)); + + // Sign, if needed + const signedEvent = await this.signWSAPIRequest(requestEvent); + + // Store deferred promise, resolved within the "resolveEmittableEvents" method while parsing incoming events + const promiseRef = getPromiseRefForWSAPIRequest(requestEvent); + + const deferredPromise = + this.getWsStore().createDeferredPromise( + wsKey, + promiseRef, + false, + ); + + this.logger.trace( + `sendWSAPIRequest(): sending raw request: ${JSON.stringify(signedEvent, null, 2)}`, + ); + + // Send event + this.tryWsSend(wsKey, JSON.stringify(signedEvent)); + + this.logger.trace(`sendWSAPIRequest(): sent ${operation} event`); + + // Return deferred promise, so caller can await this call + return deferredPromise.promise!; } } From 56e945f591137e81530c302ff8c88990612f8158 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Thu, 16 Jan 2025 20:19:59 +0000 Subject: [PATCH 05/60] feat(): include flag for parsed WS API responses --- src/util/BaseWSClient.ts | 1 + src/websocket-client.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts index dd923f2..2c23bd2 100644 --- a/src/util/BaseWSClient.ts +++ b/src/util/BaseWSClient.ts @@ -57,6 +57,7 @@ interface WSClientEventMap { export interface EmittableEvent { eventType: 'response' | 'update' | 'exception' | 'authenticated'; event: TEvent; + isWSAPIResponse?: boolean; } // Type safety for on and emit handlers: https://stackoverflow.com/a/61609010/880837 diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 025d53b..827d267 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -717,6 +717,7 @@ export class WebsocketClient extends BaseWebsocketClient { results.push({ eventType: 'exception', event: parsed, + isWSAPIResponse: true, }); return results; } @@ -743,6 +744,7 @@ export class WebsocketClient extends BaseWebsocketClient { results.push({ eventType: 'response', event: parsed, + isWSAPIResponse: true, }); return results; From 10b2af1c37e13ffe5f373baeaa375234c963b8a0 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Fri, 17 Jan 2025 13:46:49 +0000 Subject: [PATCH 06/60] fix(): wsapi response mapping --- src/types/websockets/ws-api.ts | 16 ++++++---------- src/websocket-client.ts | 13 +++++-------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/types/websockets/ws-api.ts b/src/types/websockets/ws-api.ts index ac52177..b27f9dc 100644 --- a/src/types/websockets/ws-api.ts +++ b/src/types/websockets/ws-api.ts @@ -4,6 +4,7 @@ import { CancelOrderParamsV5, OrderParamsV5, } from '../request'; +import { OrderResultV5 } from '../response'; import { WsKey } from './ws-general'; export type WSAPIOperation = 'order.create' | 'order.amend' | 'order.cancel'; @@ -58,9 +59,6 @@ export interface WsAPITopicRequestParamMap { // ping: undefined; } -export type WsAPITopicRequestParams = - WsAPITopicRequestParamMap[keyof WsAPITopicRequestParamMap]; - export interface WSAPIResponse< TResponseData extends object = object, TOperation extends WSAPIOperation = WSAPIOperation, @@ -71,7 +69,7 @@ export interface WSAPIResponse< retCode: 0 | number; retMsg: 'OK' | string; op: TOperation; - data: [TResponseData]; + data: TResponseData; header?: { 'X-Bapi-Limit': string; 'X-Bapi-Limit-Status': string; @@ -88,12 +86,10 @@ export interface WSAPIResponse< // string: object; // } -export interface WsAPIOperationResponseMap< - TResponseType extends object = object, -> { - 'order.create': WSAPIResponse; - 'order.amend': WSAPIResponse; - 'order.cancel': WSAPIResponse; +export interface WsAPIOperationResponseMap { + 'order.create': WSAPIResponse; + 'order.amend': WSAPIResponse; + 'order.cancel': WSAPIResponse; ping: { retCode: 0 | number; retMsg: 'OK' | string; diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 827d267..830178f 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -696,6 +696,7 @@ export class WebsocketClient extends BaseWebsocketClient { // WS API Exception if (isError) { + console.log('wsAPI error: ', parsed); try { this.getWsStore().rejectDeferredPromise( wsKey, @@ -908,19 +909,16 @@ export class WebsocketClient extends BaseWebsocketClient { // This overload allows the caller to omit the 3rd param, if it isn't required (e.g. for the login call) async sendWSAPIRequest< - TWSKey extends keyof WsAPIWsKeyTopicMap, + TWSKey extends keyof WsAPIWsKeyTopicMap = keyof WsAPIWsKeyTopicMap, TWSOperation extends WsAPIWsKeyTopicMap[TWSKey] = WsAPIWsKeyTopicMap[TWSKey], TWSParams extends WsAPITopicRequestParamMap[TWSOperation] = WsAPITopicRequestParamMap[TWSOperation], - TWSAPIResponse extends - | WsAPIOperationResponseMap[TWSOperation] - | object = WsAPIOperationResponseMap[TWSOperation], >( wsKey: TWSKey, operation: TWSOperation, ...params: TWSParams extends undefined ? [] : [TWSParams] - ): Promise; + ): Promise; async sendWSAPIRequest< TWSKey extends keyof WsAPIWsKeyTopicMap = keyof WsAPIWsKeyTopicMap, @@ -929,13 +927,12 @@ export class WebsocketClient extends BaseWebsocketClient { TWSParams extends WsAPITopicRequestParamMap[TWSOperation] = WsAPITopicRequestParamMap[TWSOperation], TWSAPIResponse extends - | WsAPIOperationResponseMap[TWSOperation] - | object = WsAPIOperationResponseMap[TWSOperation], + WsAPIOperationResponseMap[TWSOperation] = WsAPIOperationResponseMap[TWSOperation], >( wsKey: WsKey = WS_KEY_MAP.v5PrivateTrade, operation: TWSOperation, params: TWSParams, - ): Promise { + ): Promise { this.logger.trace(`sendWSAPIRequest(): assert "${wsKey}" is connected`); await this.assertIsConnected(wsKey); this.logger.trace('sendWSAPIRequest()->assertIsConnected() ok'); From d0eba98e06650b66fc82ed7402701590c73eae87 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Mon, 20 Jan 2025 14:22:08 +0000 Subject: [PATCH 07/60] feat(): improve wiring on promise-subscribe workflows, fixes #399 (with caveat described in PR) --- src/util/BaseWSClient.ts | 391 +++++++++++++------------- src/util/websockets/websocket-util.ts | 27 ++ src/websocket-client.ts | 359 +++++++++++++++-------- 3 files changed, 476 insertions(+), 301 deletions(-) diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts index 2c23bd2..05427ce 100644 --- a/src/util/BaseWSClient.ts +++ b/src/util/BaseWSClient.ts @@ -8,32 +8,21 @@ import { MessageEventLike, WSClientConfigurableOptions, WebsocketClientOptions, - WebsocketTopicSubscriptionConfirmationEvent, WsMarket, isMessageEvent, } from '../types'; -import { DEFERRED_PROMISE_REF, WsStore } from './websockets/WsStore'; +import { WsStore } from './websockets/WsStore'; import { WSConnectedResult, WS_LOGGER_CATEGORY, WsConnectionStateEnum, WsTopicRequest, WsTopicRequestOrStringTopic, + getNormalisedTopicRequests, safeTerminateWs, } from './websockets'; import { WsOperation } from '../types/websockets/ws-api'; -type TopicsPendingSubscriptionsResolver = () => void; -type TopicsPendingSubscriptionsRejector = (reason: string) => void; - -interface TopicsPendingSubscriptions { - wsKey: string; - failedTopicsSubscriptions: Set; - pendingTopicsSubscriptions: Set; - resolver: TopicsPendingSubscriptionsResolver; - rejector: TopicsPendingSubscriptionsRejector; -} - interface WSClientEventMap { /** Connection opened. If this connection was previously opened and reconnected, expect the reconnected event instead */ open: (evt: { wsKey: WsKey; event: any }) => void; @@ -61,7 +50,11 @@ export interface EmittableEvent { } // Type safety for on and emit handlers: https://stackoverflow.com/a/61609010/880837 -export interface BaseWebsocketClient { +export interface BaseWebsocketClient< + TWSKey extends string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + TWSRequestEvent extends object, +> { on>( event: U, listener: WSClientEventMap[U], @@ -73,31 +66,38 @@ export interface BaseWebsocketClient { ): boolean; } +// interface TopicsPendingSubscriptions { +// wsKey: string; +// failedTopicsSubscriptions: Set; +// pendingTopicsSubscriptions: Set; +// resolver: TopicsPendingSubscriptionsResolver; +// rejector: TopicsPendingSubscriptionsRejector; +// } + /** - * Users can conveniently pass topics as strings or objects (object has topic name + optional params). + * A midflight WS request event (e.g. subscribe to these topics). * - * This method normalises topics into objects (object has topic name + optional params). + * - requestKey: unique identifier for this request, if available. Can be anything as a string. + * - requestEvent: the raw request, as an object, that will be sent on the ws connection. This may contain multiple topics/requests in one object, if the exchange supports it. */ -function getNormalisedTopicRequests( - wsTopicRequests: WsTopicRequestOrStringTopic[], -): WsTopicRequest[] { - const normalisedTopicRequests: WsTopicRequest[] = []; +export interface MidflightWsRequestEvent { + requestKey: string; + requestEvent: TEvent; +} - for (const wsTopicRequest of wsTopicRequests) { - // passed as string, convert to object - if (typeof wsTopicRequest === 'string') { - const topicRequest: WsTopicRequest = { - topic: wsTopicRequest, - payload: undefined, - }; - normalisedTopicRequests.push(topicRequest); - continue; - } +type TopicsPendingSubscriptionsResolver = ( + requests: TWSRequestEvent, +) => void; - // already a normalised object, thanks to user - normalisedTopicRequests.push(wsTopicRequest); - } - return normalisedTopicRequests; +type TopicsPendingSubscriptionsRejector = ( + requests: TWSRequestEvent, + reason: string | object, +) => void; + +interface WsKeyPendingTopicSubscriptions { + requestData: TWSRequestEvent; + resolver: TopicsPendingSubscriptionsResolver; + rejector: TopicsPendingSubscriptionsRejector; } /** @@ -109,6 +109,7 @@ export abstract class BaseWebsocketClient< * The WS connections supported by the client, each identified by a unique primary key */ TWSKey extends string, + TWSRequestEvent extends object, > extends EventEmitter { /** * State store to track a list of topics (topic requests) we are expected to be subscribed to if reconnected @@ -123,7 +124,15 @@ export abstract class BaseWebsocketClient< private timeOffsetMs: number = 0; - private pendingTopicsSubscriptions: TopicsPendingSubscriptions[] = []; + // private pendingTopicsSubscriptionsOld: TopicsPendingSubscriptions[] = []; + + private pendingTopicSubscriptionRequests: { + [key in TWSKey]?: { + [requestKey: string]: + | undefined + | WsKeyPendingTopicSubscriptions; + }; + } = {}; constructor( options?: WSClientConfigurableOptions, @@ -205,9 +214,8 @@ export abstract class BaseWebsocketClient< market: WsMarket, operation: WsOperation, requests: WsTopicRequest[], - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars wsKey: TWSKey, - ): Promise; + ): Promise[]>; /** * Abstraction called to sort ws events into emittable event types (response to a request, data update, etc) @@ -251,97 +259,133 @@ export abstract class BaseWebsocketClient< this.timeOffsetMs = newOffset; } - protected upsertPendingTopicsSubscriptions( - wsKey: string, - topicKey: string, - resolver: TopicsPendingSubscriptionsResolver, - rejector: TopicsPendingSubscriptionsRejector, + // protected upsertPendingTopicsSubscriptionsOld( + // wsKey: string, + // topicKey: string, + // resolver: TopicsPendingSubscriptionsResolver, + // rejector: TopicsPendingSubscriptionsRejector, + // ) { + // const existingWsKeyPendingSubscriptions = + // this.pendingTopicsSubscriptionsOld.find((s) => s.wsKey === wsKey); + + // if (!existingWsKeyPendingSubscriptions) { + // this.pendingTopicsSubscriptionsOld.push({ + // wsKey, + // resolver, + // rejector, + // failedTopicsSubscriptions: new Set(), + // pendingTopicsSubscriptions: new Set([topicKey]), + // }); + // return; + // } + + // existingWsKeyPendingSubscriptions.pendingTopicsSubscriptions.add(topicKey); + // } + + protected upsertPendingTopicSubscribeRequests( + wsKey: TWSKey, + requestData: MidflightWsRequestEvent, ) { - const existingWsKeyPendingSubscriptions = - this.pendingTopicsSubscriptions.find((s) => s.wsKey === wsKey); - if (!existingWsKeyPendingSubscriptions) { - this.pendingTopicsSubscriptions.push({ - wsKey, - resolver, - rejector, - failedTopicsSubscriptions: new Set(), - pendingTopicsSubscriptions: new Set([topicKey]), - }); - return; + if (!this.pendingTopicSubscriptionRequests[wsKey]) { + this.pendingTopicSubscriptionRequests[wsKey] = {}; } - existingWsKeyPendingSubscriptions.pendingTopicsSubscriptions.add(topicKey); - } + const existingWsKeyPendingRequests = + this.pendingTopicSubscriptionRequests[wsKey]!; - protected removeTopicPendingSubscription(wsKey: string, topicKey: string) { - const existingWsKeyPendingSubscriptions = - this.pendingTopicsSubscriptions.find((s) => s.wsKey === wsKey); - if (existingWsKeyPendingSubscriptions) { - existingWsKeyPendingSubscriptions.pendingTopicsSubscriptions.delete( - topicKey, + // a unique identifier for this subscription request (e.g. csv of topics, or request id, etc) + const requestKey = requestData.requestKey; + + // Should not be possible to see a requestKey collision in the current design, since the req ID increments automatically with every request, so this should never be true, but just in case a future mistake happens... + if (existingWsKeyPendingRequests[requestKey]) { + throw new Error( + 'Implementation error: attempted to upsert pending topics with duplicate request ID!', ); - if (!existingWsKeyPendingSubscriptions.pendingTopicsSubscriptions.size) { - this.pendingTopicsSubscriptions = - this.pendingTopicsSubscriptions.filter((s) => s.wsKey !== wsKey); - } } - } - private clearTopicsPendingSubscriptions(wsKey: string) { - this.pendingTopicsSubscriptions = this.pendingTopicsSubscriptions.filter( - (s) => s.wsKey !== wsKey, + return new Promise( + ( + resolver: TopicsPendingSubscriptionsResolver, + rejector: TopicsPendingSubscriptionsRejector, + ) => { + if (!this.pendingTopicSubscriptionRequests[wsKey]) { + this.pendingTopicSubscriptionRequests[wsKey] = {}; + } + this.pendingTopicSubscriptionRequests[wsKey][requestKey] = { + requestData: requestData.requestEvent, + resolver, + rejector, + }; + }, ); } + protected removeTopicPendingSubscription(wsKey: TWSKey, requestKey: string) { + if (!this.pendingTopicSubscriptionRequests[wsKey]) { + this.pendingTopicSubscriptionRequests[wsKey] = {}; + } + + delete this.pendingTopicSubscriptionRequests[wsKey][requestKey]; + } + + private clearTopicsPendingSubscriptions( + wsKey: TWSKey, + rejectAll: boolean, + rejectReason: string, + ) { + if (rejectAll) { + if (!this.pendingTopicSubscriptionRequests[wsKey]) { + this.pendingTopicSubscriptionRequests[wsKey] = {}; + } + + const requests = this.pendingTopicSubscriptionRequests[wsKey]!; + for (const requestKey in requests) { + const request = requests[requestKey]; + request?.rejector(request.requestData, rejectReason); + } + } + + this.pendingTopicSubscriptionRequests[wsKey] = {}; + } + + /** + * Resolve/reject the promise for a midflight request. + * + * This will typically execute before the event is emitted. + */ protected updatePendingTopicSubscriptionStatus( - wsKey: string, - msg: WebsocketTopicSubscriptionConfirmationEvent, + wsKey: TWSKey, + requestKey: string, + msg: object, isTopicSubscriptionSuccessEvent: boolean, ) { - const requestsIds = msg.req_id as string; - const pendingTopicsSubscriptions = this.pendingTopicsSubscriptions.find( - (s) => s.wsKey === wsKey, - ); - - if (!pendingTopicsSubscriptions) { + if (!this.pendingTopicSubscriptionRequests[wsKey]) { return; } - // TODO: this assume we stored topic info in the req_id, no longer the case... cache it in a separate object? - // WARN: - console.warn('updatePendingTopicSubStatus needs update'); - const splitRequestsIds = requestsIds.split(','); - if (!isTopicSubscriptionSuccessEvent) { - splitRequestsIds.forEach((topic) => - pendingTopicsSubscriptions.failedTopicsSubscriptions.add(topic), + const pendingSubscriptionRequest = + this.pendingTopicSubscriptionRequests[wsKey][requestKey]; + if (!pendingSubscriptionRequest) { + return; + } + + console.log('updatePendingTopicSubscriptionStatus', { + isTopicSubscriptionSuccessEvent, + msg, + }); + + if (isTopicSubscriptionSuccessEvent) { + pendingSubscriptionRequest.resolver( + pendingSubscriptionRequest.requestData, + ); + } else { + pendingSubscriptionRequest.rejector( + pendingSubscriptionRequest.requestData, + msg, ); } - splitRequestsIds.forEach((topicKey) => { - this.removeTopicPendingSubscription(wsKey, topicKey); - - if ( - !pendingTopicsSubscriptions.pendingTopicsSubscriptions.size && - !pendingTopicsSubscriptions.failedTopicsSubscriptions.size - ) { - // all topics have been subscribed successfully, so we can resolve the subscription request - pendingTopicsSubscriptions.resolver(); - this.clearTopicsPendingSubscriptions(wsKey); - } - - if ( - !pendingTopicsSubscriptions.pendingTopicsSubscriptions.size && - pendingTopicsSubscriptions.failedTopicsSubscriptions.size - ) { - // not all topics have been subscribed successfully, so we reject the subscription request - // and let the caller handle the situation by providing the list of failed subscriptions requests - const failedSubscriptionsMessage = `(${[ - ...pendingTopicsSubscriptions.failedTopicsSubscriptions, - ].toString()}) failed to subscribe`; - pendingTopicsSubscriptions.rejector(failedSubscriptionsMessage); - this.clearTopicsPendingSubscriptions(wsKey); - } - }); + this.removeTopicPendingSubscription(wsKey, requestKey); } /** @@ -361,6 +405,7 @@ export abstract class BaseWebsocketClient< wsTopicRequests: WsTopicRequestOrStringTopic[], wsKey: TWSKey, ) { + console.log('subscribeTopicsForWsKey: ', { wsTopicRequests, wsKey }); const normalisedTopicRequests = getNormalisedTopicRequests(wsTopicRequests); // Store topics, so future automation (post-auth, post-reconnect) has everything needed to resubscribe automatically @@ -513,9 +558,7 @@ export abstract class BaseWebsocketClient< /** * Request connection to a specific websocket, instead of waiting for automatic connection. */ - protected async connect( - wsKey: TWSKey, - ): Promise { + public async connect(wsKey: TWSKey): Promise { try { if (this.wsStore.isWsOpen(wsKey)) { this.logger.error( @@ -770,7 +813,7 @@ export abstract class BaseWebsocketClient< topics: WsTopicRequest[], wsKey: TWSKey, operation: WsOperation, - ): Promise { + ): Promise[]> { // console.log(new Date(), `called getWsSubscribeEventsForTopics()`, topics); // console.trace(); if (!topics.length) { @@ -778,7 +821,7 @@ export abstract class BaseWebsocketClient< } // Events that are ready to send (usually stringified JSON) - const jsonStringEvents: string[] = []; + const requestEvents: MidflightWsRequestEvent[] = []; const market: WsMarket = 'all'; const maxTopicsPerEvent = this.getMaxTopicsPerSubscribeEvent(wsKey); @@ -796,12 +839,10 @@ export abstract class BaseWebsocketClient< wsKey, ); - for (const event of subscribeRequestEvents) { - jsonStringEvents.push(JSON.stringify(event)); - } + requestEvents.push(...subscribeRequestEvents); } - return jsonStringEvents; + return requestEvents; } const subscribeRequestEvents = await this.getWsRequestEvents( @@ -811,10 +852,7 @@ export abstract class BaseWebsocketClient< wsKey, ); - for (const event of subscribeRequestEvents) { - jsonStringEvents.push(JSON.stringify(event)); - } - return jsonStringEvents; + return subscribeRequestEvents; } /** @@ -824,33 +862,45 @@ export abstract class BaseWebsocketClient< */ private async requestSubscribeTopics( wsKey: TWSKey, - topics: WsTopicRequest[], + wsTopicRequests: WsTopicRequest[], ) { - if (!topics.length) { + if (!wsTopicRequests.length) { return; } // Automatically splits requests into smaller batches, if needed const subscribeWsMessages = await this.getWsOperationEventsForTopics( - topics, + wsTopicRequests, wsKey, 'subscribe', ); this.logger.trace( - `Subscribing to ${topics.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches.`, // Events: "${JSON.stringify(topics)}" + `Subscribing to ${wsTopicRequests.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches.`, // Events: "${JSON.stringify(topics)}" ); // console.log(`batches: `, JSON.stringify(subscribeWsMessages, null, 2)); - for (const wsMessage of subscribeWsMessages) { - // this.logger.trace(`Sending batch via message: "${wsMessage}"`); - this.tryWsSend(wsKey, wsMessage); + const promises: Promise[] = []; + + for (const midflightRequest of subscribeWsMessages) { + const wsMessage = midflightRequest.requestEvent; + + promises.push( + this.upsertPendingTopicSubscribeRequests(wsKey, midflightRequest), + ); + + this.logger.trace( + `Sending batch via message: "${JSON.stringify(wsMessage)}"`, + ); + this.tryWsSend(wsKey, JSON.stringify(wsMessage)); } this.logger.trace( - `Finished subscribing to ${topics.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches.`, + `Finished subscribing to ${wsTopicRequests.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches.`, ); + + return Promise.all(promises); } /** @@ -876,14 +926,24 @@ export abstract class BaseWebsocketClient< `Unsubscribing to ${wsTopicRequests.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches. Events: "${JSON.stringify(wsTopicRequests)}"`, ); - for (const wsMessage of subscribeWsMessages) { + const promises: Promise[] = []; + + for (const midflightRequest of subscribeWsMessages) { + const wsMessage = midflightRequest.requestEvent; + + promises.push( + this.upsertPendingTopicSubscribeRequests(wsKey, midflightRequest), + ); + this.logger.trace(`Sending batch via message: "${wsMessage}"`); - this.tryWsSend(wsKey, wsMessage); + this.tryWsSend(wsKey, JSON.stringify(wsMessage)); } this.logger.trace( `Finished unsubscribing to ${wsTopicRequests.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches.`, ); + + return Promise.all(promises); } /** @@ -997,60 +1057,6 @@ export abstract class BaseWebsocketClient< ) { await this.sendAuthRequest(wsKey); } - - /** - * - * WS API intialisation post-connect - * - */ - // const wsStoredState = this.wsStore.get(wsKey, true); - // const { didAuthWSAPI, WSAPIAuthChannel } = wsStoredState; - - // // If enabled, automatically reauth WS API if reconnected - // if ( - // isReconnectionAttempt && - // this.options.reauthWSAPIOnReconnect && - // didAuthWSAPI && - // WSAPIAuthChannel - // ) { - // this.logger.info( - // 'WS API was authenticated before reconnect - re-authenticating WS API...', - // ); - - // let attempt = 0; - // const maxReAuthAttempts = 5; - - // while (attempt <= maxReAuthAttempts) { - // attempt++; - // try { - // this.logger.trace( - // `try reauthenticate (attempt ${attempt}/${maxReAuthAttempts})`, - // ); - // const loginResult = await this.sendWSAPIRequest( - // wsKey, - // WSAPIAuthChannel, - // ); - // this.logger.trace('reauthenticated!', loginResult); - // break; - // } catch (e) { - // const giveUp = attempt >= maxReAuthAttempts; - - // const suffix = giveUp - // ? 'Max tries reached. Giving up!' - // : 'Trying again...'; - - // this.logger.error( - // `Exception trying to reauthenticate WS API on reconnect... ${suffix}`, - // ); - - // this.emit('exception', { - // wsKey, - // type: 'wsapi.auth', - // reason: `automatic WS API reauth failed after ${attempt} attempts`, - // }); - // } - // } - // } } /** @@ -1206,6 +1212,8 @@ export abstract class BaseWebsocketClient< 'connection lost, reconnecting', ); + this.clearTopicsPendingSubscriptions(wsKey, true, 'WS Closed'); + this.setWsState(wsKey, WsConnectionStateEnum.INITIAL); this.reconnectWithDelay(wsKey, this.options.reconnectTimeout!); @@ -1229,7 +1237,7 @@ export abstract class BaseWebsocketClient< /** * Promise-driven method to assert that a ws has successfully connected (will await until connection is open) */ - protected async assertIsConnected(wsKey: TWSKey): Promise { + public async assertIsConnected(wsKey: TWSKey): Promise { const isConnected = this.getWsStore().isConnectionState( wsKey, WsConnectionStateEnum.CONNECTED, @@ -1248,7 +1256,7 @@ export abstract class BaseWebsocketClient< this.logger.trace( 'assertIsConnected(): EXISTING connection promise resolved!', ); - return; + return inProgressPromise.promise; } // Start connection, it should automatically store/return a promise. @@ -1267,13 +1275,16 @@ export abstract class BaseWebsocketClient< /** * Promise-driven method to assert that a ws has been successfully authenticated (will await until auth is confirmed) */ - protected async assertIsAuthenticated(wsKey: TWSKey): Promise { + public async assertIsAuthenticated(wsKey: TWSKey): Promise { const isConnected = this.getWsStore().isConnectionState( wsKey, WsConnectionStateEnum.CONNECTED, ); if (!isConnected) { + this.logger.trace( + 'assertIsAuthenticated(): Not connected yet, asseting connection first', + ); await this.assertIsConnected(wsKey); } @@ -1297,7 +1308,7 @@ export abstract class BaseWebsocketClient< 'assertIsAuthenticated(): Not authenticated yet...queue await authentication...', ); - await this.connect(wsKey); + await this.sendAuthRequest(wsKey); this.logger.trace( 'assertIsAuthenticated(): Authentication promise resolved! ', diff --git a/src/util/websockets/websocket-util.ts b/src/util/websockets/websocket-util.ts index e916eda..6317a1f 100644 --- a/src/util/websockets/websocket-util.ts +++ b/src/util/websockets/websocket-util.ts @@ -633,3 +633,30 @@ export function getPromiseRefForWSAPIRequest( const promiseRef = [requestEvent.op, requestEvent.reqId].join('_'); return promiseRef; } + +/** + * Users can conveniently pass topics as strings or objects (object has topic name + optional params). + * + * This method normalises topics into objects (object has topic name + optional params). + */ +export function getNormalisedTopicRequests( + wsTopicRequests: WsTopicRequestOrStringTopic[], +): WsTopicRequest[] { + const normalisedTopicRequests: WsTopicRequest[] = []; + + for (const wsTopicRequest of wsTopicRequests) { + // passed as string, convert to object + if (typeof wsTopicRequest === 'string') { + const topicRequest: WsTopicRequest = { + topic: wsTopicRequest, + payload: undefined, + }; + normalisedTopicRequests.push(topicRequest); + continue; + } + + // already a normalised object, thanks to user + normalisedTopicRequests.push(wsTopicRequest); + } + return normalisedTopicRequests; +} diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 830178f..4feb65b 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -17,6 +17,7 @@ import { WS_KEY_MAP, WsTopicRequest, getMaxTopicsPerSubscribeEvent, + getNormalisedTopicRequests, getPromiseRefForWSAPIRequest, getWsKeyForTopic, getWsUrl, @@ -28,7 +29,11 @@ import { neverGuard, } from './util'; import { signMessage } from './util/node-support'; -import { BaseWebsocketClient, EmittableEvent } from './util/BaseWSClient'; +import { + BaseWebsocketClient, + EmittableEvent, + MidflightWsRequestEvent, +} from './util/BaseWSClient'; import { WSAPIRequest, WsAPIOperationResponseMap, @@ -41,7 +46,10 @@ import { const WS_LOGGER_CATEGORY = { category: 'bybit-ws' }; // export class WebsocketClient extends EventEmitter { -export class WebsocketClient extends BaseWebsocketClient { +export class WebsocketClient extends BaseWebsocketClient< + WsKey, + WsRequestOperationBybit +> { /** * Request connection of all dependent (public & private) websockets, instead of waiting for automatic connection by library */ @@ -72,7 +80,18 @@ export class WebsocketClient extends BaseWebsocketClient { } } - public connectPublic(): Promise[] { + /** + * Ensures the WS API connection is active and ready. + * + * You do not need to call this, but if you call this before making any WS API requests, + * it can accelerate the first request (by preparing the connection in advance). + */ + public connectWSAPI(): Promise { + /** This call automatically ensures the connection is active AND authenticated before resolving */ + return this.assertIsAuthenticated(WS_KEY_MAP.v5PrivateTrade); + } + + public connectPublic(): Promise[] { switch (this.options.market) { case 'v5': default: { @@ -152,9 +171,134 @@ export class WebsocketClient extends BaseWebsocketClient { } } + /** + * + * Subscribe to V5 topics & track/persist them. + * @param wsTopics - topic or list of topics + * @param category - the API category this topic is for (e.g. "linear"). The value is only important when connecting to public topics and will be ignored for private topics. + * @param isPrivateTopic - optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) + */ + public subscribeV5( + wsTopics: WsTopic[] | WsTopic, + category: CategoryV5, + isPrivateTopic?: boolean, + ): Promise[] { + const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; + + const perWsKeyTopics: { [key in WsKey]?: WsTopicRequest[] } = {}; + + // Sort into per-WsKey batches, in case there is a mix of topics here + for (const topic of topics) { + const derivedWsKey = getWsKeyForTopic( + this.options.market, + topic, + isPrivateTopic, + category, + ); + + const wsRequest: WsTopicRequest = { + topic: topic, + category: category, + }; + + if ( + !perWsKeyTopics[derivedWsKey] || + !Array.isArray(perWsKeyTopics[derivedWsKey]) + ) { + perWsKeyTopics[derivedWsKey] = []; + } + + perWsKeyTopics[derivedWsKey].push(wsRequest); + } + + const promises: Promise[] = []; + + // Batch sub topics per ws key + for (const wsKey in perWsKeyTopics) { + const wsKeyTopicRequests = perWsKeyTopics[wsKey as WsKey]; + if (wsKeyTopicRequests?.length) { + const requestPromise = this.subscribeTopicsForWsKey( + wsKeyTopicRequests, + wsKey as WsKey, + ); + + if (Array.isArray(requestPromise)) { + promises.push(...requestPromise); + } else { + promises.push(requestPromise); + } + } + } + + // Return promise to resolve midflight WS request (only works if already connected before request) + return promises; + } + + /** + * Unsubscribe from V5 topics & remove them from memory. They won't be re-subscribed to if the connection reconnects. + * @param wsTopics - topic or list of topics + * @param category - the API category this topic is for (e.g. "linear"). The value is only important when connecting to public topics and will be ignored for private topics. + * @param isPrivateTopic - optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) + */ + public unsubscribeV5( + wsTopics: WsTopic[] | WsTopic, + category: CategoryV5, + isPrivateTopic?: boolean, + ): Promise[] { + const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; + + const perWsKeyTopics: { [key in WsKey]?: WsTopicRequest[] } = {}; + + // Sort into per-WsKey batches, in case there is a mix of topics here + for (const topic of topics) { + const derivedWsKey = getWsKeyForTopic( + this.options.market, + topic, + isPrivateTopic, + category, + ); + + const wsRequest: WsTopicRequest = { + topic: topic, + category: category, + }; + + if ( + !perWsKeyTopics[derivedWsKey] || + !Array.isArray(perWsKeyTopics[derivedWsKey]) + ) { + perWsKeyTopics[derivedWsKey] = []; + } + + perWsKeyTopics[derivedWsKey].push(wsRequest); + } + + const promises: Promise[] = []; + + // Batch sub topics per ws key + for (const wsKey in perWsKeyTopics) { + const wsKeyTopicRequests = perWsKeyTopics[wsKey as WsKey]; + if (wsKeyTopicRequests?.length) { + const requestPromise = this.unsubscribeTopicsForWsKey( + wsKeyTopicRequests, + wsKey as WsKey, + ); + + if (Array.isArray(requestPromise)) { + promises.push(...requestPromise); + } else { + promises.push(requestPromise); + } + } + } + + // Return promise to resolve midflight WS request (only works if already connected before request) + return promises; + } + /** * Request subscription to one or more topics. Pass topics as either an array of strings, or array of objects (if the topic has parameters). - * Objects should be formatted as {topic: string, params: object}. + * Objects should be formatted as {topic: string, params: object, category: CategoryV5}. * * - Subscriptions are automatically routed to the correct websocket connection. * - Authentication/connection is automatic. @@ -166,15 +310,42 @@ export class WebsocketClient extends BaseWebsocketClient { requests: | (WsTopicRequest | WsTopic) | (WsTopicRequest | WsTopic)[], - wsKey: WsKey, + wsKey?: WsKey, ) { - if (!Array.isArray(requests)) { - this.subscribeTopicsForWsKey([requests], wsKey); - return; + const topicRequests = Array.isArray(requests) ? requests : [requests]; + const normalisedTopicRequests = getNormalisedTopicRequests(topicRequests); + + const isPrivateTopic = undefined; + + const perWsKeyTopics: { [key in WsKey]?: WsTopicRequest[] } = {}; + + // Sort into per wsKey arrays, in case topics are mixed together for different wsKeys + for (const topicRequest of normalisedTopicRequests) { + const derivedWsKey = + wsKey || + getWsKeyForTopic( + this.options.market, + topicRequest.topic, + isPrivateTopic, + topicRequest.category, + ); + + if ( + !perWsKeyTopics[derivedWsKey] || + !Array.isArray(perWsKeyTopics[derivedWsKey]) + ) { + perWsKeyTopics[derivedWsKey] = []; + } + + perWsKeyTopics[derivedWsKey].push(topicRequest); } - if (requests.length) { - this.subscribeTopicsForWsKey(requests, wsKey); + // Batch sub topics per ws key + for (const wsKey in perWsKeyTopics) { + const wsKeyTopicRequests = perWsKeyTopics[wsKey]; + if (wsKeyTopicRequests?.length) { + this.subscribeTopicsForWsKey(wsKeyTopicRequests, wsKey as WsKey); + } } } @@ -188,15 +359,42 @@ export class WebsocketClient extends BaseWebsocketClient { requests: | (WsTopicRequest | WsTopic) | (WsTopicRequest | WsTopic)[], - wsKey: WsKey, + wsKey?: WsKey, ) { - if (!Array.isArray(requests)) { - this.unsubscribeTopicsForWsKey([requests], wsKey); - return; + const topicRequests = Array.isArray(requests) ? requests : [requests]; + const normalisedTopicRequests = getNormalisedTopicRequests(topicRequests); + + const isPrivateTopic = undefined; + + const perWsKeyTopics: { [key in WsKey]?: WsTopicRequest[] } = {}; + + // Sort into per wsKey arrays, in case topics are mixed together for different wsKeys + for (const topicRequest of normalisedTopicRequests) { + const derivedWsKey = + wsKey || + getWsKeyForTopic( + this.options.market, + topicRequest.topic, + isPrivateTopic, + topicRequest.category, + ); + + if ( + !perWsKeyTopics[derivedWsKey] || + !Array.isArray(perWsKeyTopics[derivedWsKey]) + ) { + perWsKeyTopics[derivedWsKey] = []; + } + + perWsKeyTopics[derivedWsKey].push(topicRequest); } - if (requests.length) { - this.unsubscribeTopicsForWsKey(requests, wsKey); + // Batch sub topics per ws key + for (const wsKey in perWsKeyTopics) { + const wsKeyTopicRequests = perWsKeyTopics[wsKey]; + if (wsKeyTopicRequests?.length) { + this.unsubscribeTopicsForWsKey(wsKeyTopicRequests, wsKey as WsKey); + } } } @@ -214,78 +412,6 @@ export class WebsocketClient extends BaseWebsocketClient { * */ - /** - * - * Subscribe to V5 topics & track/persist them. - * @param wsTopics - topic or list of topics - * @param category - the API category this topic is for (e.g. "linear"). The value is only important when connecting to public topics and will be ignored for private topics. - * @param isPrivateTopic - optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) - */ - public subscribeV5( - wsTopics: WsTopic[] | WsTopic, - category: CategoryV5, - isPrivateTopic?: boolean, - ) { - // TODO: sort into WS key then bulk sub per wskey - const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; - - return new Promise((resolver, rejector) => { - topics.forEach((topic) => { - const wsKey = getWsKeyForTopic( - this.options.market, - topic, - isPrivateTopic, - category, - ); - - // TODO: move this to base client - this.upsertPendingTopicsSubscriptions(wsKey, topic, resolver, rejector); - - const wsRequest: WsTopicRequest = { - topic: topic, - category: category, - }; - - // Persist topic for reconnects - this.subscribeTopicsForWsKey([wsRequest], wsKey); - }); - }); - } - - /** - * Unsubscribe from V5 topics & remove them from memory. They won't be re-subscribed to if the connection reconnects. - * @param wsTopics - topic or list of topics - * @param category - the API category this topic is for (e.g. "linear"). The value is only important when connecting to public topics and will be ignored for private topics. - * @param isPrivateTopic - optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) - */ - public unsubscribeV5( - wsTopics: WsTopic[] | WsTopic, - category: CategoryV5, - isPrivateTopic?: boolean, - ) { - // TODO: sort into WS key then bulk sub per wskey - const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; - - topics.forEach((topic) => { - const wsKey = getWsKeyForTopic( - this.options.market, - topic, - isPrivateTopic, - category, - ); - - const wsRequest: WsTopicRequest = { - topic: topic, - category: category, - }; - - this.removeTopicPendingSubscription(wsKey, topic); - - // Remove topic from persistence for reconnects and unsubscribe - this.unsubscribeTopicsForWsKey([wsRequest], wsKey); - }); - } - /** * Subscribe to V1-V3 topics & track/persist them. * @@ -298,7 +424,7 @@ export class WebsocketClient extends BaseWebsocketClient { public subscribeV3( wsTopics: WsTopic[] | WsTopic, isPrivateTopic?: boolean, - ): Promise { + ): Promise[] { const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; if (this.options.market === 'v5') { topics.forEach((topic) => { @@ -310,25 +436,27 @@ export class WebsocketClient extends BaseWebsocketClient { }); } - return new Promise((resolver, rejector) => { - topics.forEach((topic) => { - const wsKey = getWsKeyForTopic( - this.options.market, - topic, - isPrivateTopic, - ); + const promises: Promise[] = []; - // TODO: move to base client - this.upsertPendingTopicsSubscriptions(wsKey, topic, resolver, rejector); + topics.forEach((topic) => { + const wsKey = getWsKeyForTopic( + this.options.market, + topic, + isPrivateTopic, + ); - const wsRequest: WsTopicRequest = { - topic: topic, - }; + const wsRequest: WsTopicRequest = { + topic: topic, + }; - // Persist topic for reconnects - this.subscribeTopicsForWsKey([wsRequest], wsKey); - }); + // Persist topic for reconnects + const requestPromise = this.subscribeTopicsForWsKey([wsRequest], wsKey); + + promises.push(requestPromise); }); + + // Return promise to resolve midflight WS request (only works if already connected before request) + return promises; } /** @@ -361,9 +489,6 @@ export class WebsocketClient extends BaseWebsocketClient { isPrivateTopic, ); - // TODO: move to base client - this.removeTopicPendingSubscription(wsKey, topic); - const wsRequest: WsTopicRequest = { topic: topic, }; @@ -484,8 +609,10 @@ export class WebsocketClient extends BaseWebsocketClient { requests: WsTopicRequest[], // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars wsKey: WsKey, - ): Promise[]> { - const wsRequestEvents: WsRequestOperationBybit[] = []; + ): Promise>[]> { + const wsRequestEvents: MidflightWsRequestEvent< + WsRequestOperationBybit + >[] = []; const wsRequestBuildingErrors: unknown[] = []; switch (market) { @@ -496,8 +623,15 @@ export class WebsocketClient extends BaseWebsocketClient { args: requests.map((r) => r.topic), }; + const midflightWsEvent: MidflightWsRequestEvent< + WsRequestOperationBybit + > = { + requestKey: wsEvent.req_id, + requestEvent: wsEvent, + }; + wsRequestEvents.push({ - ...wsEvent, + ...midflightWsEvent, }); break; } @@ -625,11 +759,14 @@ export class WebsocketClient extends BaseWebsocketClient { // parsed: JSON.stringify(parsed), // }); - if (isTopicSubscriptionConfirmation(parsed)) { + // Only applies to the V5 WS topics + if (isTopicSubscriptionConfirmation(parsed) && parsed.req_id) { const isTopicSubscriptionSuccessEvent = isTopicSubscriptionSuccess(parsed); + this.updatePendingTopicSubscriptionStatus( wsKey, + parsed.req_id, parsed, isTopicSubscriptionSuccessEvent, ); From fcffd852ccf8fe447f4060cc65aeb124c13e41c8 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 21 Jan 2025 14:26:03 +0000 Subject: [PATCH 08/60] feat(): improve e2e WS API workflow --- src/util/BaseWSClient.ts | 70 +++++------ src/websocket-client.ts | 242 ++++++++++++++------------------------- 2 files changed, 123 insertions(+), 189 deletions(-) diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts index 05427ce..c92d6c4 100644 --- a/src/util/BaseWSClient.ts +++ b/src/util/BaseWSClient.ts @@ -33,14 +33,22 @@ interface WSClientEventMap { /** Connection closed */ close: (evt: { wsKey: WsKey; event: any }) => void; /** Received reply to websocket command (e.g. after subscribing to topics) */ - response: (response: any & { wsKey: WsKey }) => void; + response: ( + response: any & { wsKey: WsKey; isWSAPIResponse?: boolean }, + ) => void; /** Received data for topic */ update: (response: any & { wsKey: WsKey }) => void; /** Exception from ws client OR custom listeners (e.g. if you throw inside your event handler) */ - exception: (response: any & { wsKey: WsKey }) => void; + exception: ( + response: any & { wsKey: WsKey; isWSAPIResponse?: boolean }, + ) => void; error: (response: any & { wsKey: WsKey }) => void; /** Confirmation that a connection successfully authenticated */ - authenticated: (event: { wsKey: WsKey; event: any }) => void; + authenticated: (event: { + wsKey: WsKey; + event: any; + isWSAPIResponse?: boolean; + }) => void; } export interface EmittableEvent { @@ -662,7 +670,7 @@ export abstract class BaseWebsocketClient< /** Get a signature, build the auth request and send it */ private async sendAuthRequest(wsKey: TWSKey): Promise { try { - this.logger.info('Sending auth request...', { + this.logger.trace('Sending auth request...', { ...WS_LOGGER_CATEGORY, wsKey, }); @@ -1160,6 +1168,11 @@ export abstract class BaseWebsocketClient< }); continue; } + const emittableFinalEvent = { + ...emittable.event, + wsKey, + isWSAPIResponse: emittable.isWSAPIResponse, + }; if (emittable.eventType === 'authenticated') { this.logger.trace('Successfully authenticated', { @@ -1167,12 +1180,12 @@ export abstract class BaseWebsocketClient< wsKey, emittable, }); - this.emit(emittable.eventType, { ...emittable.event, wsKey }); + this.emit(emittable.eventType, emittableFinalEvent); this.onWsAuthenticated(wsKey, emittable.event); continue; } - this.emit(emittable.eventType, { ...emittable.event, wsKey }); + this.emit(emittable.eventType, emittableFinalEvent); } return; @@ -1203,6 +1216,9 @@ export abstract class BaseWebsocketClient< wsKey, }); + const wsState = this.wsStore.get(wsKey, true); + wsState.isAuthenticated = false; + if ( this.wsStore.getConnectionState(wsKey) !== WsConnectionStateEnum.CLOSING ) { @@ -1249,26 +1265,18 @@ export abstract class BaseWebsocketClient< // Already in progress? Await shared promise and retry if (inProgressPromise) { - this.logger.trace( - 'assertIsConnected(): Awaiting EXISTING connection promise...', - ); + this.logger.trace('assertIsConnected(): awaiting...'); await inProgressPromise.promise; - this.logger.trace( - 'assertIsConnected(): EXISTING connection promise resolved!', - ); + this.logger.trace('assertIsConnected(): connected!'); return inProgressPromise.promise; } // Start connection, it should automatically store/return a promise. - this.logger.trace( - 'assertIsConnected(): Not connected yet...queue await connection...', - ); + this.logger.trace('assertIsConnected(): connecting...'); await this.connect(wsKey); - this.logger.trace( - 'assertIsConnected(): New connection promise resolved! ', - ); + this.logger.trace('assertIsConnected(): newly connected!'); } } @@ -1282,9 +1290,7 @@ export abstract class BaseWebsocketClient< ); if (!isConnected) { - this.logger.trace( - 'assertIsAuthenticated(): Not connected yet, asseting connection first', - ); + this.logger.trace('assertIsAuthenticated(): connecting...'); await this.assertIsConnected(wsKey); } @@ -1293,25 +1299,23 @@ export abstract class BaseWebsocketClient< // Already in progress? Await shared promise and retry if (inProgressPromise) { - this.logger.trace( - 'assertIsAuthenticated(): Awaiting EXISTING authentication promise...', - ); + this.logger.trace('assertIsAuthenticated(): awaiting...'); await inProgressPromise.promise; - this.logger.trace( - 'assertIsAuthenticated(): EXISTING authentication promise resolved!', - ); + this.logger.trace('assertIsAuthenticated(): authenticated!'); + return; + } + + const isAuthenticated = this.wsStore.get(wsKey)?.isAuthenticated; + if (isAuthenticated) { + this.logger.trace('assertIsAuthenticated(): ok'); return; } // Start authentication, it should automatically store/return a promise. - this.logger.trace( - 'assertIsAuthenticated(): Not authenticated yet...queue await authentication...', - ); + this.logger.trace('assertIsAuthenticated(): authenticating...'); await this.sendAuthRequest(wsKey); - this.logger.trace( - 'assertIsAuthenticated(): Authentication promise resolved! ', - ); + this.logger.trace('assertIsAuthenticated(): newly authenticated!'); } } diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 4feb65b..d4456c0 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -415,6 +415,8 @@ export class WebsocketClient extends BaseWebsocketClient< /** * Subscribe to V1-V3 topics & track/persist them. * + * @deprecated The V1-V3 websockets are very old and may not work properly anymore. Support for them will be removed soon. Use subcribeV5/unsubscribeV5 or subscribe/unsubscribe instead. + * * Note: for public V5 topics use the `subscribeV5()` method. * * Topics will be automatically resubscribed to if the connection resets/drops/reconnects. @@ -462,6 +464,8 @@ export class WebsocketClient extends BaseWebsocketClient< /** * Unsubscribe from V1-V3 topics & remove them from memory. They won't be re-subscribed to if the connection reconnects. * + * @deprecated The V1-V3 websockets are very old and may not work properly anymore. Support for them will be removed soon. Use subcribeV5/unsubscribeV5 or subscribe/unsubscribe instead. + * * Note: For public V5 topics, use `unsubscribeV5()` instead! * * @param wsTopics topic or list of topics @@ -750,6 +754,7 @@ export class WebsocketClient extends BaseWebsocketClient< event: MessageEventLike, ): EmittableEvent[] { const results: EmittableEvent[] = []; + // const isWSAPIResponseEvent = wsKey === WS_KEY_MAP.v5PrivateTrade; try { const parsed = JSON.parse(event.data); @@ -784,11 +789,89 @@ export class WebsocketClient extends BaseWebsocketClient< const eventTopic = parsed?.topic; const eventOperation = parsed?.op; + // WS API response + if (isWSAPIResponse(parsed)) { + const retCode = parsed.retCode; + const reqId = parsed.reqId; + + const isError = retCode !== 0; + + const promiseRef = [parsed.op, reqId].join('_'); + + if (!reqId) { + this.logger.error( + 'WS API response is missing reqId - promisified workflow could get stuck. If this happens, please get in touch with steps to reproduce. Trace:', + { + wsKey, + promiseRef, + parsedEvent: parsed, + }, + ); + } + + // WS API Exception + if (isError) { + // console.log('wsAPI error: ', parsed); + try { + this.getWsStore().rejectDeferredPromise( + wsKey, + promiseRef, + { + wsKey, + ...parsed, + }, + true, + ); + } catch (e) { + this.logger.error('Exception trying to reject WSAPI promise', { + wsKey, + promiseRef, + parsedEvent: parsed, + }); + } + + results.push({ + eventType: 'exception', + event: parsed, + isWSAPIResponse: true, + }); + return results; + } + + // WS API Success + try { + this.getWsStore().resolveDeferredPromise( + wsKey, + promiseRef, + { + wsKey, + ...parsed, + }, + true, + ); + } catch (e) { + this.logger.error('Exception trying to resolve WSAPI promise', { + wsKey, + promiseRef, + parsedEvent: parsed, + }); + } + + results.push({ + eventType: 'response', + event: parsed, + isWSAPIResponse: true, + }); + + return results; + } + // Messages for a subscribed topic all include the "topic" property if (typeof eventTopic === 'string') { results.push({ eventType: 'update', event: parsed, + // isWSAPIResponse: isWSAPIResponseEvent, }); return results; } @@ -800,6 +883,7 @@ export class WebsocketClient extends BaseWebsocketClient< results.push({ eventType: 'exception', event: parsed, + // isWSAPIResponse: isWSAPIResponseEvent, }); return results; } @@ -809,6 +893,7 @@ export class WebsocketClient extends BaseWebsocketClient< results.push({ eventType: 'response', event: parsed, + // isWSAPIResponse: isWSAPIResponseEvent, }); return results; } @@ -818,95 +903,11 @@ export class WebsocketClient extends BaseWebsocketClient< results.push({ eventType: 'authenticated', event: parsed, + // isWSAPIResponse: isWSAPIResponseEvent, }); return results; } - // WS API response - if (isWSAPIResponse(parsed)) { - const retCode = parsed.retCode; - const reqId = parsed.reqId; - - const isError = retCode !== 0; - - const promiseRef = [parsed.op, reqId].join('_'); - - // WS API Exception - if (isError) { - console.log('wsAPI error: ', parsed); - try { - this.getWsStore().rejectDeferredPromise( - wsKey, - promiseRef, - { - wsKey, - ...parsed, - }, - true, - ); - } catch (e) { - this.logger.error('Exception trying to reject WSAPI promise', { - wsKey, - promiseRef, - parsedEvent: parsed, - }); - } - - results.push({ - eventType: 'exception', - event: parsed, - isWSAPIResponse: true, - }); - return results; - } - - // WS API Success - try { - this.getWsStore().resolveDeferredPromise( - wsKey, - promiseRef, - { - wsKey, - ...parsed, - }, - true, - ); - } catch (e) { - this.logger.error('Exception trying to resolve WSAPI promise', { - wsKey, - promiseRef, - parsedEvent: parsed, - }); - } - - results.push({ - eventType: 'response', - event: parsed, - isWSAPIResponse: true, - }); - - return results; - } - - // const wsAPIExample = { - // reqId: '1', - // retCode: 0, - // retMsg: 'OK', - // op: 'order.create', - // data: { - // orderId: '454c62ab-cb89-4f19-b70e-6123d3a53817', - // orderLinkId: '', - // }, - // header: { - // 'X-Bapi-Limit': '10', - // 'X-Bapi-Limit-Status': '9', - // 'X-Bapi-Limit-Reset-Timestamp': '1737041109260', - // Traceid: '7e34e1105f093eff75dd7de0f1a59771', - // Timenow: '1737041109263', - // }, - // connId: 'ctb9l5v88smdae1fivmg-5esl', - // }; - this.logger.error( `!! Unhandled string operation type "${eventOperation}". Defaulting to "update" channel...`, parsed, @@ -918,77 +919,6 @@ export class WebsocketClient extends BaseWebsocketClient< ); } - // TODO: WS API - // const eventChannel = parsed.op; - // const requestId = parsed?.request_id; - // const promiseRef = [eventChannel, requestId].join('_'); - // if (eventType === 'api') { - // const isError = eventStatusCode !== '200'; - - // // WS API Exception - // if (isError) { - // try { - // this.getWsStore().rejectDeferredPromise( - // wsKey, - // promiseRef, - // { - // wsKey, - // ...parsed, - // }, - // true, - // ); - // } catch (e) { - // this.logger.error('Exception trying to reject WSAPI promise', { - // wsKey, - // promiseRef, - // parsedEvent: parsed, - // }); - // } - - // results.push({ - // eventType: 'exception', - // event: parsed, - // }); - // return results; - // } - - // // WS API Success - // try { - // this.getWsStore().resolveDeferredPromise( - // wsKey, - // promiseRef, - // { - // wsKey, - // ...parsed, - // }, - // true, - // ); - // } catch (e) { - // this.logger.error('Exception trying to resolve WSAPI promise', { - // wsKey, - // promiseRef, - // parsedEvent: parsed, - // }); - // } - - // if (eventChannel.includes('.login')) { - // results.push({ - // eventType: 'authenticated', - // event: { - // ...parsed, - // isWSAPI: true, - // WSAPIAuthChannel: eventChannel, - // }, - // }); - // } - - // results.push({ - // eventType: 'response', - // event: parsed, - // }); - // return results; - // } - // In case of catastrophic failure, fallback to noisy emit update results.push({ eventType: 'update', From 39ce4c4929431aa0c0a49e8a0c6d0b5bb871e445 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 21 Jan 2025 15:22:43 +0000 Subject: [PATCH 09/60] chore(): misc cleaning --- package-lock.json | 4 +- src/util/BaseWSClient.ts | 77 ++++++++--------------------- src/websocket-client.ts | 103 ++++++++++++++++++--------------------- tsconfig.json | 2 +- 4 files changed, 71 insertions(+), 115 deletions(-) diff --git a/package-lock.json b/package-lock.json index 940089b..372a1a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bybit-api", - "version": "3.10.28", + "version": "3.10.29", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "bybit-api", - "version": "3.10.28", + "version": "3.10.29", "license": "MIT", "dependencies": { "axios": "^1.6.6", diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts index c92d6c4..38c7c52 100644 --- a/src/util/BaseWSClient.ts +++ b/src/util/BaseWSClient.ts @@ -132,8 +132,6 @@ export abstract class BaseWebsocketClient< private timeOffsetMs: number = 0; - // private pendingTopicsSubscriptionsOld: TopicsPendingSubscriptions[] = []; - private pendingTopicSubscriptionRequests: { [key in TWSKey]?: { [requestKey: string]: @@ -259,52 +257,32 @@ export abstract class BaseWebsocketClient< params: any, ): Promise; - protected getTimeOffsetMs() { + public getTimeOffsetMs() { return this.timeOffsetMs; } - protected setTimeOffsetMs(newOffset: number) { + public setTimeOffsetMs(newOffset: number) { this.timeOffsetMs = newOffset; } - // protected upsertPendingTopicsSubscriptionsOld( - // wsKey: string, - // topicKey: string, - // resolver: TopicsPendingSubscriptionsResolver, - // rejector: TopicsPendingSubscriptionsRejector, - // ) { - // const existingWsKeyPendingSubscriptions = - // this.pendingTopicsSubscriptionsOld.find((s) => s.wsKey === wsKey); + private getWsKeyPendingSubscriptionStore(wsKey: TWSKey) { + if (!this.pendingTopicSubscriptionRequests[wsKey]) { + this.pendingTopicSubscriptionRequests[wsKey] = {}; + } - // if (!existingWsKeyPendingSubscriptions) { - // this.pendingTopicsSubscriptionsOld.push({ - // wsKey, - // resolver, - // rejector, - // failedTopicsSubscriptions: new Set(), - // pendingTopicsSubscriptions: new Set([topicKey]), - // }); - // return; - // } - - // existingWsKeyPendingSubscriptions.pendingTopicsSubscriptions.add(topicKey); - // } + return this.pendingTopicSubscriptionRequests[wsKey]!; + } protected upsertPendingTopicSubscribeRequests( wsKey: TWSKey, requestData: MidflightWsRequestEvent, ) { - if (!this.pendingTopicSubscriptionRequests[wsKey]) { - this.pendingTopicSubscriptionRequests[wsKey] = {}; - } - - const existingWsKeyPendingRequests = - this.pendingTopicSubscriptionRequests[wsKey]!; - // a unique identifier for this subscription request (e.g. csv of topics, or request id, etc) const requestKey = requestData.requestKey; // Should not be possible to see a requestKey collision in the current design, since the req ID increments automatically with every request, so this should never be true, but just in case a future mistake happens... + const existingWsKeyPendingRequests = + this.getWsKeyPendingSubscriptionStore(wsKey); if (existingWsKeyPendingRequests[requestKey]) { throw new Error( 'Implementation error: attempted to upsert pending topics with duplicate request ID!', @@ -316,10 +294,8 @@ export abstract class BaseWebsocketClient< resolver: TopicsPendingSubscriptionsResolver, rejector: TopicsPendingSubscriptionsRejector, ) => { - if (!this.pendingTopicSubscriptionRequests[wsKey]) { - this.pendingTopicSubscriptionRequests[wsKey] = {}; - } - this.pendingTopicSubscriptionRequests[wsKey][requestKey] = { + const store = this.getWsKeyPendingSubscriptionStore(wsKey); + store[requestKey] = { requestData: requestData.requestEvent, resolver, rejector, @@ -329,11 +305,8 @@ export abstract class BaseWebsocketClient< } protected removeTopicPendingSubscription(wsKey: TWSKey, requestKey: string) { - if (!this.pendingTopicSubscriptionRequests[wsKey]) { - this.pendingTopicSubscriptionRequests[wsKey] = {}; - } - - delete this.pendingTopicSubscriptionRequests[wsKey][requestKey]; + const store = this.getWsKeyPendingSubscriptionStore(wsKey); + delete store[requestKey]; } private clearTopicsPendingSubscriptions( @@ -342,13 +315,9 @@ export abstract class BaseWebsocketClient< rejectReason: string, ) { if (rejectAll) { - if (!this.pendingTopicSubscriptionRequests[wsKey]) { - this.pendingTopicSubscriptionRequests[wsKey] = {}; - } - - const requests = this.pendingTopicSubscriptionRequests[wsKey]!; - for (const requestKey in requests) { - const request = requests[requestKey]; + const wsKeyPendingRequests = this.getWsKeyPendingSubscriptionStore(wsKey); + for (const requestKey in wsKeyPendingRequests) { + const request = wsKeyPendingRequests[requestKey]; request?.rejector(request.requestData, rejectReason); } } @@ -367,21 +336,16 @@ export abstract class BaseWebsocketClient< msg: object, isTopicSubscriptionSuccessEvent: boolean, ) { - if (!this.pendingTopicSubscriptionRequests[wsKey]) { + const wsKeyPendingRequests = this.getWsKeyPendingSubscriptionStore(wsKey); + if (!wsKeyPendingRequests) { return; } - const pendingSubscriptionRequest = - this.pendingTopicSubscriptionRequests[wsKey][requestKey]; + const pendingSubscriptionRequest = wsKeyPendingRequests[requestKey]; if (!pendingSubscriptionRequest) { return; } - console.log('updatePendingTopicSubscriptionStatus', { - isTopicSubscriptionSuccessEvent, - msg, - }); - if (isTopicSubscriptionSuccessEvent) { pendingSubscriptionRequest.resolver( pendingSubscriptionRequest.requestData, @@ -413,7 +377,6 @@ export abstract class BaseWebsocketClient< wsTopicRequests: WsTopicRequestOrStringTopic[], wsKey: TWSKey, ) { - console.log('subscribeTopicsForWsKey: ', { wsTopicRequests, wsKey }); const normalisedTopicRequests = getNormalisedTopicRequests(wsTopicRequests); // Store topics, so future automation (post-auth, post-reconnect) has everything needed to resubscribe automatically diff --git a/src/websocket-client.ts b/src/websocket-client.ts index d4456c0..193ae15 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -45,6 +45,46 @@ import { const WS_LOGGER_CATEGORY = { category: 'bybit-ws' }; +/** + * Groups topics in request into per-wsKey groups + * @param normalisedTopicRequests + * @param wsKey + * @param isPrivateTopic + * @returns + */ +function getTopicsPerWSKey( + normalisedTopicRequests: WsTopicRequest[], + wsKey?: WsKey, + isPrivateTopic?: boolean, +): { + [key in WsKey]?: WsTopicRequest[]; +} { + const perWsKeyTopics: { [key in WsKey]?: WsTopicRequest[] } = {}; + + // Sort into per wsKey arrays, in case topics are mixed together for different wsKeys + for (const topicRequest of normalisedTopicRequests) { + const derivedWsKey = + wsKey || + getWsKeyForTopic( + this.options.market, + topicRequest.topic, + isPrivateTopic, + topicRequest.category, + ); + + if ( + !perWsKeyTopics[derivedWsKey] || + !Array.isArray(perWsKeyTopics[derivedWsKey]) + ) { + perWsKeyTopics[derivedWsKey] = []; + } + + perWsKeyTopics[derivedWsKey]!.push(topicRequest); + } + + return perWsKeyTopics; +} + // export class WebsocketClient extends EventEmitter { export class WebsocketClient extends BaseWebsocketClient< WsKey, @@ -183,12 +223,12 @@ export class WebsocketClient extends BaseWebsocketClient< category: CategoryV5, isPrivateTopic?: boolean, ): Promise[] { - const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; + const topicRequests = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; const perWsKeyTopics: { [key in WsKey]?: WsTopicRequest[] } = {}; // Sort into per-WsKey batches, in case there is a mix of topics here - for (const topic of topics) { + for (const topic of topicRequests) { const derivedWsKey = getWsKeyForTopic( this.options.market, topic, @@ -208,7 +248,7 @@ export class WebsocketClient extends BaseWebsocketClient< perWsKeyTopics[derivedWsKey] = []; } - perWsKeyTopics[derivedWsKey].push(wsRequest); + perWsKeyTopics[derivedWsKey]!.push(wsRequest); } const promises: Promise[] = []; @@ -245,12 +285,12 @@ export class WebsocketClient extends BaseWebsocketClient< category: CategoryV5, isPrivateTopic?: boolean, ): Promise[] { - const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; + const topicRequests = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; const perWsKeyTopics: { [key in WsKey]?: WsTopicRequest[] } = {}; // Sort into per-WsKey batches, in case there is a mix of topics here - for (const topic of topics) { + for (const topic of topicRequests) { const derivedWsKey = getWsKeyForTopic( this.options.market, topic, @@ -270,7 +310,7 @@ export class WebsocketClient extends BaseWebsocketClient< perWsKeyTopics[derivedWsKey] = []; } - perWsKeyTopics[derivedWsKey].push(wsRequest); + perWsKeyTopics[derivedWsKey]!.push(wsRequest); } const promises: Promise[] = []; @@ -315,30 +355,7 @@ export class WebsocketClient extends BaseWebsocketClient< const topicRequests = Array.isArray(requests) ? requests : [requests]; const normalisedTopicRequests = getNormalisedTopicRequests(topicRequests); - const isPrivateTopic = undefined; - - const perWsKeyTopics: { [key in WsKey]?: WsTopicRequest[] } = {}; - - // Sort into per wsKey arrays, in case topics are mixed together for different wsKeys - for (const topicRequest of normalisedTopicRequests) { - const derivedWsKey = - wsKey || - getWsKeyForTopic( - this.options.market, - topicRequest.topic, - isPrivateTopic, - topicRequest.category, - ); - - if ( - !perWsKeyTopics[derivedWsKey] || - !Array.isArray(perWsKeyTopics[derivedWsKey]) - ) { - perWsKeyTopics[derivedWsKey] = []; - } - - perWsKeyTopics[derivedWsKey].push(topicRequest); - } + const perWsKeyTopics = getTopicsPerWSKey(normalisedTopicRequests, wsKey); // Batch sub topics per ws key for (const wsKey in perWsKeyTopics) { @@ -364,30 +381,7 @@ export class WebsocketClient extends BaseWebsocketClient< const topicRequests = Array.isArray(requests) ? requests : [requests]; const normalisedTopicRequests = getNormalisedTopicRequests(topicRequests); - const isPrivateTopic = undefined; - - const perWsKeyTopics: { [key in WsKey]?: WsTopicRequest[] } = {}; - - // Sort into per wsKey arrays, in case topics are mixed together for different wsKeys - for (const topicRequest of normalisedTopicRequests) { - const derivedWsKey = - wsKey || - getWsKeyForTopic( - this.options.market, - topicRequest.topic, - isPrivateTopic, - topicRequest.category, - ); - - if ( - !perWsKeyTopics[derivedWsKey] || - !Array.isArray(perWsKeyTopics[derivedWsKey]) - ) { - perWsKeyTopics[derivedWsKey] = []; - } - - perWsKeyTopics[derivedWsKey].push(topicRequest); - } + const perWsKeyTopics = getTopicsPerWSKey(normalisedTopicRequests, wsKey); // Batch sub topics per ws key for (const wsKey in perWsKeyTopics) { @@ -811,7 +805,6 @@ export class WebsocketClient extends BaseWebsocketClient< // WS API Exception if (isError) { - // console.log('wsAPI error: ', parsed); try { this.getWsStore().rejectDeferredPromise( wsKey, diff --git a/tsconfig.json b/tsconfig.json index bf49483..9454c28 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,6 @@ "baseUrl": ".", "outDir": "./lib" }, - "include": ["src/**/*", "src/.ts"], + "include": ["src/**/*"], "exclude": ["node_modules", "**/node_modules/*", "coverage", "doc"] } From 50bae8b1c2ca5216e5352e0171c57f6c0f6fa2a3 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 21 Jan 2025 15:26:40 +0000 Subject: [PATCH 10/60] chore(): bump typescript and types/node versions --- examples/ws-public-v5.ts | 15 ++++++++---- package-lock.json | 49 +++++++++++++++++++++------------------- package.json | 4 ++-- src/websocket-client.ts | 2 +- 4 files changed, 40 insertions(+), 30 deletions(-) diff --git a/examples/ws-public-v5.ts b/examples/ws-public-v5.ts index 9a8dff8..4201ed2 100644 --- a/examples/ws-public-v5.ts +++ b/examples/ws-public-v5.ts @@ -68,6 +68,17 @@ wsClient.on('reconnected', (data) => { // Option v5 // wsClient.subscribeV5('publicTrade.BTC', 'option'); +const topics = ['kline.5.XRPUSDT', 'kline.5.BTCUSDT', 'kline.5.ETHUSDT']; + +// Use the subscribeV5() call for most subscribe calls with v5 websockets +wsClient.subscribeV5(topics, 'spot'); + +// Alternatively, you can also use objects in the wsClient.subscribe() call +// wsClient.subscribe({ +// topic: 'orderook.50.BTCUSDT', +// category: 'spot', +// }); + /** * For private V5 topics, just call the same subscribeV5() method on the ws client or use the original subscribe() method. * @@ -79,10 +90,6 @@ wsClient.on('reconnected', (data) => { // wsClient.subscribeV5('execution', 'linear'); // wsClient.subscribeV5(['order', 'wallet', 'greek'], 'linear'); -const topics = ['kline.5.XRPUSDT', 'kline.5.BTCUSDT', 'kline.5.ETHUSDT']; - -wsClient.subscribeV5(topics, 'spot'); - // To unsubscribe from topics (after a 5 second delay, in this example): setTimeout(() => { console.log('unsubscribing'); diff --git a/package-lock.json b/package-lock.json index 372a1a9..907c768 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@types/jest": "^29.5.11", - "@types/node": "^20.11.6", + "@types/node": "^22.10.7", "@typescript-eslint/eslint-plugin": "^7.1.0", "@typescript-eslint/parser": "^7.1.0", "eslint": "^8.29.0", @@ -25,7 +25,7 @@ "jest": "^29.7.0", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", - "typescript": "^5.3.3" + "typescript": "^5.7.3" }, "funding": { "type": "individual", @@ -1695,12 +1695,13 @@ "optional": true }, "node_modules/@types/node": { - "version": "20.11.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.6.tgz", - "integrity": "sha512-+EOokTnksGVgip2PbYbr3xnR7kZigh4LbybAfBAw5BpnQ+FqBYUsvCEjYd70IXKlbohQ64mzEYmMtlWUY8q//Q==", + "version": "22.10.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz", + "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", "devOptional": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.20.0" } }, "node_modules/@types/stack-utils": { @@ -6573,10 +6574,11 @@ } }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "devOptional": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6586,10 +6588,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "devOptional": true + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "devOptional": true, + "license": "MIT" }, "node_modules/update-browserslist-db": { "version": "1.1.0", @@ -8330,12 +8333,12 @@ "optional": true }, "@types/node": { - "version": "20.11.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.6.tgz", - "integrity": "sha512-+EOokTnksGVgip2PbYbr3xnR7kZigh4LbybAfBAw5BpnQ+FqBYUsvCEjYd70IXKlbohQ64mzEYmMtlWUY8q//Q==", + "version": "22.10.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz", + "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", "devOptional": true, "requires": { - "undici-types": "~5.26.4" + "undici-types": "~6.20.0" } }, "@types/stack-utils": { @@ -11925,15 +11928,15 @@ "dev": true }, "typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "devOptional": true }, "undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "devOptional": true }, "update-browserslist-db": { diff --git a/package.json b/package.json index 7363a0e..4a495d3 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ }, "devDependencies": { "@types/jest": "^29.5.11", - "@types/node": "^20.11.6", + "@types/node": "^22.10.7", "@typescript-eslint/eslint-plugin": "^7.1.0", "@typescript-eslint/parser": "^7.1.0", "eslint": "^8.29.0", @@ -40,7 +40,7 @@ "jest": "^29.7.0", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", - "typescript": "^5.3.3" + "typescript": "^5.7.3" }, "optionalDependencies": { "source-map-loader": "^2.0.0", diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 193ae15..cd397ff 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -248,7 +248,7 @@ export class WebsocketClient extends BaseWebsocketClient< perWsKeyTopics[derivedWsKey] = []; } - perWsKeyTopics[derivedWsKey]!.push(wsRequest); + perWsKeyTopics[derivedWsKey].push(wsRequest); } const promises: Promise[] = []; From 13cc5dd7022f0261d091b43d7ef7fd1ac9f2a6a5 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 21 Jan 2025 15:45:27 +0000 Subject: [PATCH 11/60] chore(): tidier types for pending subs, bump axios to latest --- jsconfig.json | 15 ++++++++------- package-lock.json | 15 ++++++++------- package.json | 2 +- src/util/BaseWSClient.ts | 36 +++++++++++++++++++----------------- 4 files changed, 36 insertions(+), 32 deletions(-) diff --git a/jsconfig.json b/jsconfig.json index 5816065..9253e24 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,12 +1,13 @@ { "compilerOptions": { - "target": "ES6", - "module": "commonjs" + "target": "ES6", + "module": "commonjs" }, "exclude": [ - "node_modules", - "**/node_modules/*", - "coverage", - "doc" + "node_modules", + "**/node_modules/*", + "coverage", + "doc", + "examples/ignored/*" ] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index 907c768..db075f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "3.10.29", "license": "MIT", "dependencies": { - "axios": "^1.6.6", + "axios": "^1.7.9", "isomorphic-ws": "^4.0.1", "ws": "^7.4.0" }, @@ -2294,9 +2294,10 @@ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "node_modules/axios": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", - "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -8784,9 +8785,9 @@ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "axios": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", - "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", "requires": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", diff --git a/package.json b/package.json index 4a495d3..6318778 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "Stefan Aebischer (https://pixtron.ch)" ], "dependencies": { - "axios": "^1.6.6", + "axios": "^1.7.9", "isomorphic-ws": "^4.0.1", "ws": "^7.4.0" }, diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts index 38c7c52..cccf766 100644 --- a/src/util/BaseWSClient.ts +++ b/src/util/BaseWSClient.ts @@ -132,13 +132,14 @@ export abstract class BaseWebsocketClient< private timeOffsetMs: number = 0; - private pendingTopicSubscriptionRequests: { - [key in TWSKey]?: { - [requestKey: string]: - | undefined - | WsKeyPendingTopicSubscriptions; - }; - } = {}; + /** + * A nested wsKey->request key store. + * pendingTopicSubscriptionRequests[wsKey][requestKey] = WsKeyPendingTopicSubscriptions + */ + private pendingTopicSubscriptionRequests: Record< + string, + Record> + > = {}; constructor( options?: WSClientConfigurableOptions, @@ -281,9 +282,9 @@ export abstract class BaseWebsocketClient< const requestKey = requestData.requestKey; // Should not be possible to see a requestKey collision in the current design, since the req ID increments automatically with every request, so this should never be true, but just in case a future mistake happens... - const existingWsKeyPendingRequests = - this.getWsKeyPendingSubscriptionStore(wsKey); - if (existingWsKeyPendingRequests[requestKey]) { + + const pendingSubReqs = this.getWsKeyPendingSubscriptionStore(wsKey); + if (pendingSubReqs[requestKey]) { throw new Error( 'Implementation error: attempted to upsert pending topics with duplicate request ID!', ); @@ -294,8 +295,8 @@ export abstract class BaseWebsocketClient< resolver: TopicsPendingSubscriptionsResolver, rejector: TopicsPendingSubscriptionsRejector, ) => { - const store = this.getWsKeyPendingSubscriptionStore(wsKey); - store[requestKey] = { + const pendingSubReqs = this.getWsKeyPendingSubscriptionStore(wsKey); + pendingSubReqs[requestKey] = { requestData: requestData.requestEvent, resolver, rejector, @@ -305,8 +306,8 @@ export abstract class BaseWebsocketClient< } protected removeTopicPendingSubscription(wsKey: TWSKey, requestKey: string) { - const store = this.getWsKeyPendingSubscriptionStore(wsKey); - delete store[requestKey]; + const pendingSubReqs = this.getWsKeyPendingSubscriptionStore(wsKey); + delete pendingSubReqs[requestKey]; } private clearTopicsPendingSubscriptions( @@ -315,9 +316,10 @@ export abstract class BaseWebsocketClient< rejectReason: string, ) { if (rejectAll) { - const wsKeyPendingRequests = this.getWsKeyPendingSubscriptionStore(wsKey); - for (const requestKey in wsKeyPendingRequests) { - const request = wsKeyPendingRequests[requestKey]; + const pendingSubReqs = this.getWsKeyPendingSubscriptionStore(wsKey); + + for (const requestKey in pendingSubReqs) { + const request = pendingSubReqs[requestKey]; request?.rejector(request.requestData, rejectReason); } } From 13cd799e7c6f1a166702eafea6015466c01869ff Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 21 Jan 2025 16:47:14 +0000 Subject: [PATCH 12/60] feat(): use web crypto API by default for sign, expose param to inject custom sign function --- examples/fasterHmacSign.ts | 183 +++++++++++++++++++++++++++++ src/types/websockets/ws-general.ts | 7 -- src/util/BaseRestClient.ts | 29 ++++- src/util/BaseWSClient.ts | 2 - src/util/browser-support.ts | 29 ----- src/util/node-support.ts | 9 -- src/util/requestUtils.ts | 7 ++ src/util/webCryptoAPI.ts | 84 +++++++++++++ src/websocket-client.ts | 20 +++- webpack/webpack.config.js | 2 - 10 files changed, 317 insertions(+), 55 deletions(-) create mode 100644 examples/fasterHmacSign.ts delete mode 100644 src/util/browser-support.ts delete mode 100644 src/util/node-support.ts create mode 100644 src/util/webCryptoAPI.ts diff --git a/examples/fasterHmacSign.ts b/examples/fasterHmacSign.ts new file mode 100644 index 0000000..818087d --- /dev/null +++ b/examples/fasterHmacSign.ts @@ -0,0 +1,183 @@ +import { createHmac } from 'crypto'; +import { DefaultLogger, RestClientV5, WebsocketClient } from '../src/index'; + +// or +// import { createHmac } from 'crypto'; +// import { DefaultLogger, RestClientV5, WebsocketClient } from 'bybit-api'; + +/** + * Injecting a custom signMessage function. + * + * As of version 4.0.0 of the bybit-api Node.js/TypeScript/JavaScript + * SDK for Bybit, the SDK uses the Web Crypto API for signing requests. + * While it is compatible with Node and Browser environments, it is + * slightly slower than using Node's native crypto module (only + * available in backend Node environments). + * + * For latency sensitive users, you can inject the previous node crypto sign + * method (or your own even faster-implementation), if this change affects you. + * + * This example demonstrates how to inject a custom sign function, to achieve + * the same peformance as seen before the Web Crypto API was introduced. + * + * For context on standard usage, the "signMessage" function is used: + * - During every single API call + * - After opening a new private WebSocket connection + * + */ + +const key = process.env.API_KEY_COM; +const secret = process.env.API_SECRET_COM; + +const restClient = new RestClientV5({ + key: key, + secret: secret, + parseAPIRateLimits: true, + /** + * Set this to true to enable demo trading: + */ + demoTrading: true, + /** + * Overkill in almost every case, but if you need any optimisation available, + * you can inject a faster sign mechanism such as node's native createHmac: + */ + customSignMessageFn: async (message, secret) => { + return createHmac('sha256', secret).update(message).digest('hex'); + }, +}); + +// Optional, uncomment the "silly" override to log a lot more info about what the WS client is doing +const customLogger = { + ...DefaultLogger, + // silly: (...params) => console.log('trace', ...params), +}; + +const wsClient = new WebsocketClient( + { + key: key, + secret: secret, + /** + * Set this to true to enable demo trading for the private account data WS + * Topics: order,execution,position,wallet,greeks + */ + demoTrading: true, + /** + * Overkill in almost every case, but if you need any optimisation available, + * you can inject a faster sign mechanism such as node's native createHmac: + */ + customSignMessageFn: async (message, secret) => { + return createHmac('sha256', secret).update(message).digest('hex'); + }, + }, + customLogger, +); + +function setWsClientEventListeners( + websocketClient: WebsocketClient, + accountRef: string, +): Promise { + return new Promise((resolve) => { + websocketClient.on('update', (data) => { + console.log(new Date(), accountRef, 'data ', JSON.stringify(data)); + // console.log('raw message received ', JSON.stringify(data, null, 2)); + }); + + websocketClient.on('open', (data) => { + console.log( + new Date(), + accountRef, + 'connection opened open:', + data.wsKey, + ); + }); + websocketClient.on('response', (data) => { + console.log( + new Date(), + accountRef, + 'log response: ', + JSON.stringify(data, null, 2), + ); + + if (typeof data.req_id === 'string') { + const topics = data.req_id.split(','); + if (topics.length) { + console.log(new Date(), accountRef, 'Subscribed to topics: ', topics); + return resolve(); + } + } + }); + websocketClient.on('reconnect', ({ wsKey }) => { + console.log( + new Date(), + accountRef, + 'ws automatically reconnecting.... ', + wsKey, + ); + }); + websocketClient.on('reconnected', (data) => { + console.log(new Date(), accountRef, 'ws has reconnected ', data?.wsKey); + }); + websocketClient.on('error', (data) => { + console.error(new Date(), accountRef, 'ws exception: ', data); + }); + }); +} + +(async () => { + try { + const onSubscribed = setWsClientEventListeners(wsClient, 'demoAcc'); + + wsClient.subscribeV5(['position', 'execution', 'wallet'], 'linear'); + + // Simple promise to ensure we're subscribed before trying anything else + await onSubscribed; + + // Start trading + const balResponse1 = await restClient.getWalletBalance({ + accountType: 'UNIFIED', + }); + console.log('balResponse1: ', JSON.stringify(balResponse1, null, 2)); + + const demoFunds = await restClient.requestDemoTradingFunds(); + console.log('requested demo funds: ', demoFunds); + + const balResponse2 = await restClient.getWalletBalance({ + accountType: 'UNIFIED', + }); + console.log('balResponse2: ', JSON.stringify(balResponse2, null, 2)); + + /** Simple examples for private REST API calls with bybit's V5 REST APIs */ + const response = await restClient.getPositionInfo({ + category: 'linear', + symbol: 'BTCUSDT', + }); + + console.log('response:', response); + + // Trade USDT linear perps + const buyOrderResult = await restClient.submitOrder({ + category: 'linear', + symbol: 'BTCUSDT', + orderType: 'Market', + qty: '1', + side: 'Buy', + }); + console.log('buyOrderResult:', buyOrderResult); + + const sellOrderResult = await restClient.submitOrder({ + category: 'linear', + symbol: 'BTCUSDT', + orderType: 'Market', + qty: '1', + side: 'Sell', + }); + console.log('sellOrderResult:', sellOrderResult); + + const balResponse3 = await restClient.getWalletBalance({ + accountType: 'UNIFIED', + }); + console.log('balResponse2: ', JSON.stringify(balResponse3, null, 2)); + } catch (e) { + console.error('request failed: ', e); + } +})(); diff --git a/src/types/websockets/ws-general.ts b/src/types/websockets/ws-general.ts index 26f3fbb..130e2c5 100644 --- a/src/types/websockets/ws-general.ts +++ b/src/types/websockets/ws-general.ts @@ -138,12 +138,6 @@ export interface WSClientConfigurableOptions { * Look in the examples folder for a demonstration on using node's createHmac instead. */ customSignMessageFn?: (message: string, secret: string) => Promise; - - /** - * If you authenticated the WS API before, automatically try to - * re-authenticate the WS API if you're disconnected/reconnected for any reason. - */ - reauthWSAPIOnReconnect?: boolean; } /** @@ -158,5 +152,4 @@ export interface WebsocketClientOptions extends WSClientConfigurableOptions { recvWindow: number; authPrivateConnectionsOnConnect: boolean; authPrivateRequests: boolean; - reauthWSAPIOnReconnect: boolean; } diff --git a/src/util/BaseRestClient.ts b/src/util/BaseRestClient.ts index eb46c7f..5b43ae2 100644 --- a/src/util/BaseRestClient.ts +++ b/src/util/BaseRestClient.ts @@ -11,7 +11,7 @@ import { parseRateLimitHeaders, serializeParams, } from './requestUtils'; -import { signMessage } from './node-support'; +import { SignAlgorithm, SignEncodeMethod, signMessage } from './webCryptoAPI'; const ENABLE_HTTP_TRACE = typeof process === 'object' && @@ -394,6 +394,18 @@ export default abstract class BaseRestClient { }; } + private async signMessage( + paramsStr: string, + secret: string, + method: SignEncodeMethod, + algorithm: SignAlgorithm, + ): Promise { + if (typeof this.options.customSignMessageFn === 'function') { + return this.options.customSignMessageFn(paramsStr, secret); + } + return await signMessage(paramsStr, secret, method, algorithm); + } + /** * @private sign request and set recv window */ @@ -441,7 +453,13 @@ export default abstract class BaseRestClient { const paramsStr = timestamp + key + recvWindow + signRequestParams; - res.sign = await signMessage(paramsStr, this.secret); + res.sign = await this.signMessage( + paramsStr, + this.secret, + 'hex', + 'SHA-256', + ); + res.serializedParams = signRequestParams; // console.log('sign req: ', { @@ -473,7 +491,12 @@ export default abstract class BaseRestClient { sortProperties, encodeValues, ); - res.sign = await signMessage(res.serializedParams, this.secret); + res.sign = await this.signMessage( + res.serializedParams, + this.secret, + 'hex', + 'SHA-256', + ); // @ts-ignore res.paramsWithSign = { diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts index cccf766..8edd0fa 100644 --- a/src/util/BaseWSClient.ts +++ b/src/util/BaseWSClient.ts @@ -167,8 +167,6 @@ export abstract class BaseWebsocketClient< authPrivateConnectionsOnConnect: true, // Individual requests do not require a signature, so this is disabled. authPrivateRequests: false, - // Automatically re-authenticate the WS API connection, if previously authenticated. TODO: - reauthWSAPIOnReconnect: true, ...options, }; diff --git a/src/util/browser-support.ts b/src/util/browser-support.ts deleted file mode 100644 index 280ce37..0000000 --- a/src/util/browser-support.ts +++ /dev/null @@ -1,29 +0,0 @@ -export async function signMessage( - message: string, - secret: string, -): Promise { - const encoder = new TextEncoder(); - // eslint-disable-next-line no-undef - const key = await window.crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: { name: 'SHA-256' } }, - false, - ['sign'], - ); - - // eslint-disable-next-line no-undef - const signature = await window.crypto.subtle.sign( - 'HMAC', - key, - encoder.encode(message), - ); - - return Array.prototype.map - .call( - new Uint8Array(signature), - (x: { toString: (arg0: number) => string }) => - ('00' + x.toString(16)).slice(-2), - ) - .join(''); -} diff --git a/src/util/node-support.ts b/src/util/node-support.ts deleted file mode 100644 index 0d352d7..0000000 --- a/src/util/node-support.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createHmac } from 'crypto'; - -/** This is async because the browser version uses a promise (browser-support) */ -export async function signMessage( - message: string, - secret: string, -): Promise { - return createHmac('sha256', secret).update(message).digest('hex'); -} diff --git a/src/util/requestUtils.ts b/src/util/requestUtils.ts index 89c14db..492f7a5 100644 --- a/src/util/requestUtils.ts +++ b/src/util/requestUtils.ts @@ -77,6 +77,13 @@ export interface RestClientOptions { /** Default: false. Enable to throw error if rate limit parser fails */ throwOnFailedRateLimitParse?: boolean; + + /** + * Allows you to provide a custom "signMessage" function, e.g. to use node's much faster createHmac method + * + * Look in the examples folder for a demonstration on using node's createHmac instead. + */ + customSignMessageFn?: (message: string, secret: string) => Promise; } /** diff --git a/src/util/webCryptoAPI.ts b/src/util/webCryptoAPI.ts new file mode 100644 index 0000000..2aea730 --- /dev/null +++ b/src/util/webCryptoAPI.ts @@ -0,0 +1,84 @@ +import { neverGuard } from './websockets'; + +function bufferToB64(buffer: ArrayBuffer): string { + let binary = ''; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return globalThis.btoa(binary); +} + +export type SignEncodeMethod = 'hex' | 'base64'; +export type SignAlgorithm = 'SHA-256' | 'SHA-512'; + +/** + * Similar to node crypto's `createHash()` function + */ +export async function hashMessage( + message: string, + method: SignEncodeMethod, + algorithm: SignAlgorithm, +): Promise { + const encoder = new TextEncoder(); + + const buffer = await globalThis.crypto.subtle.digest( + algorithm, + encoder.encode(message), + ); + + switch (method) { + case 'hex': { + return Array.from(new Uint8Array(buffer)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + } + case 'base64': { + return bufferToB64(buffer); + } + default: { + throw neverGuard(method, `Unhandled sign method: "${method}"`); + } + } +} + +/** + * Sign a message, with a secret, using the Web Crypto API + */ +export async function signMessage( + message: string, + secret: string, + method: SignEncodeMethod, + algorithm: SignAlgorithm, +): Promise { + const encoder = new TextEncoder(); + + const key = await globalThis.crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: algorithm }, + false, + ['sign'], + ); + + const buffer = await globalThis.crypto.subtle.sign( + 'HMAC', + key, + encoder.encode(message), + ); + + switch (method) { + case 'hex': { + return Array.from(new Uint8Array(buffer)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + } + case 'base64': { + return bufferToB64(buffer); + } + default: { + throw neverGuard(method, `Unhandled sign method: "${method}"`); + } + } +} diff --git a/src/websocket-client.ts b/src/websocket-client.ts index cd397ff..03c23f9 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -28,7 +28,6 @@ import { isWsPong, neverGuard, } from './util'; -import { signMessage } from './util/node-support'; import { BaseWebsocketClient, EmittableEvent, @@ -42,6 +41,7 @@ import { WsOperation, WsRequestOperationBybit, } from './types/websockets/ws-api'; +import { SignAlgorithm, signMessage } from './util/webCryptoAPI'; const WS_LOGGER_CATEGORY = { category: 'bybit-ws' }; @@ -527,6 +527,18 @@ export class WebsocketClient extends BaseWebsocketClient< return ''; } + private async signMessage( + paramsStr: string, + secret: string, + method: 'hex' | 'base64', + algorithm: SignAlgorithm, + ): Promise { + if (typeof this.options.customSignMessageFn === 'function') { + return this.options.customSignMessageFn(paramsStr, secret); + } + return await signMessage(paramsStr, secret, method, algorithm); + } + protected async getWsAuthRequestEvent(wsKey: WsKey): Promise { try { const { signature, expiresAt } = await this.getWsAuthSignature(wsKey); @@ -566,9 +578,11 @@ export class WebsocketClient extends BaseWebsocketClient< const signatureExpiresAt = Date.now() + this.getTimeOffsetMs() + recvWindow; - const signature = await signMessage( + const signature = await this.signMessage( 'GET/realtime' + signatureExpiresAt, secret, + 'hex', + 'SHA-256', ); return { @@ -951,7 +965,7 @@ export class WebsocketClient extends BaseWebsocketClient< * * Returned promise is rejected if an exception is detected in the reply OR the connection disconnects for any reason (even if automatic reconnect will happen). * - * After a fresh connection, you should always send a login request first. + * Authentication is automatic. If you didn't request authentication yourself, there might be a small delay after your first request, while the SDK automatically authenticates. * * If you authenticated once and you're reconnected later (e.g. connection temporarily lost), the SDK will by default automatically: * - Detect you were authenticated to the WS API before diff --git a/webpack/webpack.config.js b/webpack/webpack.config.js index 75aefc3..4b2617f 100644 --- a/webpack/webpack.config.js +++ b/webpack/webpack.config.js @@ -19,8 +19,6 @@ function generateConfig(name) { // Add '.ts' and '.tsx' as resolvable extensions. extensions: [".webpack.js", ".web.js", ".ts", ".tsx", ".js"], alias: { - [path.resolve(__dirname, "../lib/util/node-support.js")]: - path.resolve(__dirname, "../lib/util/browser-support.js"), process: "process/browser", } }, From 98d2331f0e6579c92272ba8ce3251e3caa03e601 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Wed, 22 Jan 2025 12:07:05 +0000 Subject: [PATCH 13/60] feat(): add example for REST-like WS API usage for Bybit in Node.js/JavaScript/TypeScript. Update type flowing and docs for stricter types. --- README.md | 69 +++++++------ examples/ws-api-promises.ts | 176 +++++++++++++++++++++++++++++++++ examples/ws-private-v5.ts | 3 +- src/types/websockets/ws-api.ts | 80 +++++---------- src/util/BaseWSClient.ts | 11 +-- src/websocket-client.ts | 63 +++++++----- 6 files changed, 284 insertions(+), 118 deletions(-) create mode 100644 examples/ws-api-promises.ts diff --git a/README.md b/README.md index bb57b33..4a722fc 100644 --- a/README.md +++ b/README.md @@ -19,19 +19,31 @@ [1]: https://www.npmjs.com/package/bybit-api -Node.js & JavaScript SDK for the Bybit REST APIs and WebSockets: +Node.js, JavaScript & TypeScript SDK for the Bybit REST APIs and WebSockets: -- Complete integration with all Bybit REST APIs & WebSockets. +- Complete integration with all Bybit REST APIs & WebSockets, including the WebSocket API. - Actively maintained with a modern, promise-driven interface. - TypeScript support (with type declarations for most API requests & responses). -- Over 450 end-to-end tests making real API calls & WebSocket connections, validating any changes before they reach npm. +- Thorough end-to-end tests making real API calls & WebSocket connections, validating any changes before they reach npm. +- Proxy support via axios integration. - Robust WebSocket integration with configurable connection heartbeats & automatic reconnect then resubscribe workflows. - Event driven messaging. - Smart websocket persistence - Automatically handle silent websocket disconnections through timed heartbeats, including the scheduled 24hr disconnect. - Automatically handle listenKey persistence and expiration/refresh. - Emit `reconnected` event when dropped connection is restored. -- Proxy support via axios integration. +- WebSocket API integration, with two design patterns to choose from: + - Asynchronous event-driven responses: + - Subscribe to `response` and `error` events from WebsocketClient's event emitter. + - Send commands with the sendWSAPIRequest(...) method. + - Responses to commands will arrive via the `response` and `error` events. + - See example for more details: [examples/ws-api-events.ts](./examples/ws-api-events.ts) + - Asynchronous promise-driven responses: + - This behaves very much like a REST API. No need to subscribe to asynchronous events. + - Send commands with the await sendWSAPIRequest(...) method. + - Await responses to commands directly in the fully typed sendWSAPIRequest() call. + - The method directly returns a promise. Use a try/catch block for convenient error handling without the complexity of asynchronous WebSockets. + - See example for more details: [examples/ws-api-promises.ts](./examples/ws-api-promises.ts) - Active community support & collaboration in telegram: [Node.js Algo Traders](https://t.me/nodetraders). ## Installation @@ -88,23 +100,17 @@ The version on npm is the output from the `build` command and can be used in pro ## REST API Clients -Bybit has several API groups (originally one per product). Each generation is labelled with the version number (e.g. v1/v2/v3/v5). New projects & developments should use the newest available API generation (e.g. use the V5 APIs instead of V3). +Bybit used to have several API groups (originally one per product). You should be using the V5 APIs. If you aren't, you should upgrade your project to use the V5 APIs as soon as possible. -Refer to the [V5 interface mapping page](https://bybit-exchange.github.io/docs/v5/intro#v5-and-v3-interface-mapping-list) for more information on which V5 endpoints can be used instead of previous V3 endpoints. +Refer to the [V5 interface mapping page](https://bybit-exchange.github.io/docs/v5/intro#v5-and-v3-interface-mapping-list) for more information on which V5 endpoints can be used instead of previous V3 endpoints. To learn more about the V5 API, please read the [V5 upgrade guideline](https://bybit-exchange.github.io/docs/v5/upgrade-guide). Here are the available REST clients and the corresponding API groups described in the documentation: -| Class | Description | -| :----------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | -| [ **V5 API** ] | The new unified V5 APIs (successor to previously fragmented APIs for all API groups). To learn more about the V5 API, please read the [V5 upgrade guideline](https://bybit-exchange.github.io/docs/v5/upgrade-guide). | -| [RestClientV5](src/rest-client-v5.ts) | Unified V5 all-in-one REST client for all [V5 REST APIs](https://bybit-exchange.github.io/docs/v5/intro) | -| [WebsocketClient](src/websocket-client.ts) | All WebSocket Events (Public & Private for all API categories) | -| [ **Derivatives v3** ] | The Derivatives v3 APIs (successor to the Futures V2 APIs) | -| [UnifiedMarginClient](src/unified-margin-client.ts) | [Derivatives (v3) Unified Margin APIs](https://bybit-exchange.github.io/docs/derivatives/unified/place-order) | -| [ContractClient](src/contract-client.ts) | [Derivatives (v3) Contract APIs](https://bybit-exchange.github.io/docs/derivatives/contract/place-order). | -| [ **Other** ] | Other standalone API groups | -| [CopyTradingClient](src/copy-trading-client.ts) | [Copy Trading APIs](https://bybit-exchange.github.io/docs/category/copy-trade) | -| [AccountAssetClientV3](src/account-asset-client-v3.ts) | [Account Asset V3 APIs](https://bybit-exchange.github.io/docs/account-asset/internal-transfer) | +| Class | Description | +| :----------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------: | +| [ **V5 API** ] | The new unified V5 APIs (successor to previously fragmented APIs for all API groups). | +| [RestClientV5](src/rest-client-v5.ts) | Unified V5 all-in-one REST client for all [V5 REST APIs](https://bybit-exchange.github.io/docs/v5/intro) | +| [WebsocketClient](src/websocket-client.ts) | All WebSocket Events (Public & Private for all API categories) | --- @@ -112,11 +118,16 @@ Here are the available REST clients and the corresponding API groups described i The following API clients are for previous generation REST APIs and will be removed in the next major release. Some have already stopped working (because bybit stopped supporting them). You should use the V5 APIs for all new development. +Each generation is labelled with the version number (e.g. v1/v2/v3/v5). New projects & developments should use the newest available API generation (e.g. use the V5 APIs instead of V3). +
Click me to see the list of APIs | Class | Description | | :--------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------: | +| [ **Derivatives v3** ] | The Derivatives v3 APIs (successor to the Futures V2 APIs) | +| [UnifiedMarginClient](src/unified-margin-client.ts) |[Derivatives (v3) Unified Margin APIs](https://bybit-exchange.github.io/docs/derivatives/unified/place-order) | +| [ContractClient](src/contract-client.ts) | [Derivatives (v3) Contract APIs](https://bybit-exchange.github.io/docs/derivatives/contract/place-order). | | [ **Futures v2** ] | The Futures v2 APIs | | [~~InverseClient~~](src/inverse-client.ts) | [Inverse Perpetual Futures (v2) APIs](https://bybit-exchange.github.io/docs/futuresV2/inverse/) | | [~~LinearClient~~](src/linear-client.ts) | [USDT Perpetual Futures (v2) APIs](https://bybit-exchange.github.io/docs/futuresV2/linear/#t-introduction) | @@ -127,7 +138,10 @@ The following API clients are for previous generation REST APIs and will be remo | [ **USDC Contract** ] | The USDC Contract APIs | | [USDCPerpetualClient](src/usdc-perpetual-client.ts) | [USDC Perpetual APIs](https://bybit-exchange.github.io/docs/usdc/option/?console#t-querydeliverylog) | | [USDCOptionClient](src/usdc-option-client.ts) | [USDC Option APIs](https://bybit-exchange.github.io/docs/usdc/option/#t-introduction) | -| [~~AccountAssetClient~~](src/account-asset-client.ts) (deprecated, AccountAssetClientV3 recommended) | [Account Asset V1 APIs](https://bybit-exchange.github.io/docs/account_asset/v1/#t-introduction) | +| [~~AccountAssetClient~~](src/account-asset-client.ts) | [Account Asset V1 APIs](https://bybit-exchange.github.io/docs/account_asset/v1/#t-introduction) | +| [ **Other** ] | Other standalone API groups | +| [CopyTradingClient](src/copy-trading-client.ts) | [Copy Trading APIs](https://bybit-exchange.github.io/docs/category/copy-trade) | +| [AccountAssetClientV3](src/account-asset-client-v3.ts) | [Account Asset V3 APIs](https://bybit-exchange.github.io/docs/account-asset/internal-transfer) |
@@ -318,12 +332,6 @@ const wsConfig = { const ws = new WebsocketClient(wsConfig); -// (before v5) subscribe to multiple topics at once -ws.subscribe(['position', 'execution', 'trade']); - -// (before v5) and/or subscribe to individual topics on demand -ws.subscribe('kline.BTCUSD.1m'); - // (v5) subscribe to multiple topics at once ws.subscribeV5(['orderbook.50.BTCUSDT', 'orderbook.50.ETHUSDT'], 'linear'); @@ -351,7 +359,7 @@ ws.on('close', () => { console.log('connection closed'); }); -// Optional: Listen to raw error events. Recommended. +// Listen to raw error events. Recommended. ws.on('error', (err) => { console.error('error', err); }); @@ -370,13 +378,13 @@ Pass a custom logger (or mutate the imported DefaultLogger class) which supports ```javascript const { WebsocketClient, DefaultLogger } = require('bybit-api'); -// Disable all logging on the silly level +// Enable all logging on the trace level (disabled by default) const customLogger = { ...DefaultLogger, - silly: () => {}, + trace: (...params) => console.log('silly', ...params), }; -const ws = new WebsocketClient({ key: 'xxx', secret: 'yyy' }, customLogger); +const wsClient = new WebsocketClient({ key: 'xxx', secret: 'yyy' }, customLogger); ``` ### Debug HTTP requests @@ -391,16 +399,13 @@ This is the "modern" way, allowing the package to be directly imported into fron 1. Install these dependencies ```sh - npm install crypto-browserify stream-browserify + npm install stream-browserify ``` 2. Add this to your `tsconfig.json` ```json { "compilerOptions": { "paths": { - "crypto": [ - "./node_modules/crypto-browserify" - ], "stream": [ "./node_modules/stream-browserify" ] diff --git a/examples/ws-api-promises.ts b/examples/ws-api-promises.ts new file mode 100644 index 0000000..5be77d9 --- /dev/null +++ b/examples/ws-api-promises.ts @@ -0,0 +1,176 @@ +import { DefaultLogger, WS_KEY_MAP, WebsocketClient } from '../src'; + +// or +// import { DefaultLogger, WS_KEY_MAP, WebsocketClient } from 'bybit-api'; + +const logger = { + ...DefaultLogger, + // For a more detailed view of the WebsocketClient, enable the `trace` level by uncommenting the below line: + // trace: (...params) => console.log('trace', ...params), +}; + +const key = process.env.API_KEY; +const secret = process.env.API_SECRET; + +const wsClient = new WebsocketClient( + { + key: key, + secret: secret, + // testnet: true, // Whether to use the testnet environment: https://testnet.bybit.com/app/user/api-management + // demoTrading: false, // note: As of Jan 2025, demo trading does NOT support the WS API + }, + logger, // Optional: inject a custom logger +); + +/** + * General event handlers for monitoring the WebsocketClient + */ +wsClient.on('update', (data) => { + console.log('raw message received ', JSON.stringify(data)); +}); +wsClient.on('open', (data) => { + console.log('ws connected', data.wsKey); +}); +wsClient.on('reconnect', ({ wsKey }) => { + console.log('ws automatically reconnecting.... ', wsKey); +}); +wsClient.on('reconnected', (data) => { + console.log('ws has reconnected ', data?.wsKey); +}); +wsClient.on('authenticated', (data) => { + console.log('ws has authenticated ', data?.wsKey); +}); +wsClient.on('error', (data) => { + console.error('ws error: ', data); +}); + +async function main() { + /** + * + * This SDK's WebSocket API integration can connect WS API responses to the request that caused them. Each call + * to the `sendWSAPIRequest(...)` method returns a promise. + * + * This promise will resolve when the matching response is detected, and reject if an exception for that request + * is detected. This allows using Bybit's Websocket API in the same way that a REST API normally works. + * + * Send a command and immediately await the result. Handle any exceptions in a catch block. + * + * TypeScript users can benefit from smart type flowing for increased type safety & convenience: + * - Request parameters are fully typed, depending on the operation in the second parameter to the call. E.g. + * the `order.create` operation will automatically require the params to match the `OrderParamsV5` interface. + * + * - Response parameters are fully typed, depending on the operation in the second parameter. E.g the `order.create` + * operation will automatically map the returned value to `WSAPIResponse`. + * + */ + + // To make it easier to watch, wait a few seconds before sending the amend order + const AMEND_AFTER_SECONDS = 5; + + // Then wait a few more before sending the cancel order + const CANCEL_AFTER_SECONDS = 10; + + /** + * + * If you haven't connected yet, the WebsocketClient will automatically connect and authenticate you as soon as you send + * your first command. That connection will then be reused for every command you send, unless the connection drops - then + * it will automatically be replaced with a healthy connection. + * + * This "not connected yet" scenario can add an initial delay to your first command. If you want to prepare a connection + * in advance, you can ask the WebsocketClient to prepare it before you start submitting commands. This is optional. + * + */ + + // Optional, see above. Can be used to prepare a connection before sending commands + await wsClient.connectWSAPI(); + + /** + * Create a new order + */ + + let orderId: string | undefined; + + try { + console.log('Step 1: Create an order'); + + // The type for `wsAPISubmitOrderResult` is automatically resolved to `WSAPIResponse` + const wsAPISubmitOrderResult = await wsClient.sendWSAPIRequest( + WS_KEY_MAP.v5PrivateTrade, + 'order.create', + { + symbol: 'BTCUSDT', + side: 'Buy', + orderType: 'Limit', + price: '50000', + qty: '1', + category: 'linear', + }, + ); + + // Save the orderId for the next call + orderId = wsAPISubmitOrderResult.data.orderId; + + console.log( + `Step 1: Order result (order ID: "${orderId}"): `, + wsAPISubmitOrderResult, + ); + } catch (e) { + console.error('Step 1: Order submit exception: ', e); + return; + } + + setTimeout(async () => { + try { + console.log('Step 2: Amend an order'); + + // The type for `wsAPIAmendOrderResult` is automatically resolved to `WSAPIResponse` + const wsAPIAmendOrderResult = await wsClient.sendWSAPIRequest( + WS_KEY_MAP.v5PrivateTrade, + 'order.amend', + { + symbol: 'BTCUSDT', + category: 'linear', + orderId, + price: '55000', + }, + ); + + // Save the orderId for the next call + orderId = wsAPIAmendOrderResult.data.orderId; + + console.log( + `Step 2: Amend result (order ID: "${orderId}"): `, + wsAPIAmendOrderResult, + ); + } catch (e) { + console.error('Step 2: Amend order exception: ', e); + return; + } + }, AMEND_AFTER_SECONDS * 1000); + + setTimeout(async () => { + try { + console.log('Step 3: Cancel an order'); + + // The type for `wsAPICancelOrderResult` is automatically resolved to `WSAPIResponse` + const wsAPICancelOrderResult = await wsClient.sendWSAPIRequest( + WS_KEY_MAP.v5PrivateTrade, + 'order.cancel', + { + category: 'linear', + symbol: 'BTCUSDT', + orderId, + }, + ); + + console.log('Step 3: Cancel result:', wsAPICancelOrderResult); + } catch (e) { + console.error('Step 3: Cancel order exception: ', e); + } + + process.exit(-1); + }, CANCEL_AFTER_SECONDS * 1000); +} + +// Start executing the example workflow +main(); diff --git a/examples/ws-private-v5.ts b/examples/ws-private-v5.ts index 95f224b..868e350 100644 --- a/examples/ws-private-v5.ts +++ b/examples/ws-private-v5.ts @@ -26,10 +26,9 @@ const wsClient = new WebsocketClient( { key: key, secret: secret, - market: 'v5', testnet: true, }, - logger + logger, ); wsClient.on('update', (data) => { diff --git a/src/types/websockets/ws-api.ts b/src/types/websockets/ws-api.ts index b27f9dc..f47c562 100644 --- a/src/types/websockets/ws-api.ts +++ b/src/types/websockets/ws-api.ts @@ -22,15 +22,10 @@ export const WS_API_Operations: WSAPIOperation[] = [ 'order.cancel', ]; -export interface WsRequestOperationBybit< - TWSTopic extends string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - // TWSPayload = any, -> { +export interface WsRequestOperationBybit { req_id: string; op: WsOperation; args?: (TWSTopic | string | number)[]; - // payload?: TWSPayload; } export interface WSAPIRequest< @@ -48,17 +43,6 @@ export interface WSAPIRequest< args: [TRequestParams]; } -export interface WsAPIWsKeyTopicMap { - [WS_KEY_MAP.v5PrivateTrade]: WSAPIOperation; -} - -export interface WsAPITopicRequestParamMap { - 'order.create': OrderParamsV5; - 'order.amend': AmendOrderParamsV5; - 'order.cancel': CancelOrderParamsV5; - // ping: undefined; -} - export interface WSAPIResponse< TResponseData extends object = object, TOperation extends WSAPIOperation = WSAPIOperation, @@ -80,12 +64,32 @@ export interface WSAPIResponse< connId: string; } -// export interface WsAPIResponseMap { -// 'spot.login': WSAPIResponse; -// 'futures.login': WSAPIResponse; -// string: object; -// } +export type Exact = { + // This part says: if there's any key that's not in T, it's an error + [K: string]: never; +} & { + [K in keyof T]: T[K]; +}; +/** + * List of operations supported for this WsKey (connection) + */ +export interface WsAPIWsKeyTopicMap { + [WS_KEY_MAP.v5PrivateTrade]: WSAPIOperation; +} + +/** + * Request parameters expected per operation + */ +export interface WsAPITopicRequestParamMap { + 'order.create': OrderParamsV5; + 'order.amend': AmendOrderParamsV5; + 'order.cancel': CancelOrderParamsV5; +} + +/** + * Response structure expected for each operation + */ export interface WsAPIOperationResponseMap { 'order.create': WSAPIResponse; 'order.amend': WSAPIResponse; @@ -97,36 +101,4 @@ export interface WsAPIOperationResponseMap { data: [string]; connId: string; }; - - // 'spot.login': WSAPIResponse; - // 'futures.login': WSAPIResponse; - - // 'spot.order_place': WSAPIResponse; - // 'spot.order_cancel': WSAPIResponse; - // 'spot.order_cancel_ids': WSAPIResponse< - // TResponseType, - // 'spot.order_cancel_ids' - // >; - // 'spot.order_cancel_cp': WSAPIResponse; - // 'spot.order_amend': WSAPIResponse; - // 'spot.order_status': WSAPIResponse< - // WSAPIOrderStatusResponse, - // 'spot.order_status' - // >; - // 'futures.order_place': WSAPIResponse; - // 'futures.order_batch_place': WSAPIResponse< - // TResponseType[], - // 'futures.order_batch_place' - // >; - // 'futures.order_cancel': WSAPIResponse; - // 'futures.order_cancel_cp': WSAPIResponse< - // TResponseType, - // 'futures.order_cancel_cp' - // >; - // 'futures.order_amend': WSAPIResponse; - // 'futures.order_list': WSAPIResponse; - // 'futures.order_status': WSAPIResponse< - // WSAPIOrderStatusResponse, - // 'futures.order_status' - // >; } diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts index 8edd0fa..03b3844 100644 --- a/src/util/BaseWSClient.ts +++ b/src/util/BaseWSClient.ts @@ -39,10 +39,7 @@ interface WSClientEventMap { /** Received data for topic */ update: (response: any & { wsKey: WsKey }) => void; /** Exception from ws client OR custom listeners (e.g. if you throw inside your event handler) */ - exception: ( - response: any & { wsKey: WsKey; isWSAPIResponse?: boolean }, - ) => void; - error: (response: any & { wsKey: WsKey }) => void; + error: (response: any & { wsKey: WsKey; isWSAPIResponse?: boolean }) => void; /** Confirmation that a connection successfully authenticated */ authenticated: (event: { wsKey: WsKey; @@ -52,7 +49,7 @@ interface WSClientEventMap { } export interface EmittableEvent { - eventType: 'response' | 'update' | 'exception' | 'authenticated'; + eventType: 'response' | 'update' | 'error' | 'authenticated'; event: TEvent; isWSAPIResponse?: boolean; } @@ -594,7 +591,7 @@ export abstract class BaseWebsocketClient< if (!error.message) { this.logger.error(`${context} due to unexpected error: `, error); this.emit('response', { ...error, wsKey }); - this.emit('exception', { ...error, wsKey }); + this.emit('error', { ...error, wsKey }); return; } @@ -627,7 +624,7 @@ export abstract class BaseWebsocketClient< } this.emit('response', { ...error, wsKey }); - this.emit('exception', { ...error, wsKey }); + this.emit('error', { ...error, wsKey }); } /** Get a signature, build the auth request and send it */ diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 03c23f9..5e70b24 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -34,6 +34,7 @@ import { MidflightWsRequestEvent, } from './util/BaseWSClient'; import { + Exact, WSAPIRequest, WsAPIOperationResponseMap, WsAPITopicRequestParamMap, @@ -838,7 +839,7 @@ export class WebsocketClient extends BaseWebsocketClient< } results.push({ - eventType: 'exception', + eventType: 'error', event: parsed, isWSAPIResponse: true, }); @@ -888,7 +889,7 @@ export class WebsocketClient extends BaseWebsocketClient< // Failed request if (parsed.success === false) { results.push({ - eventType: 'exception', + eventType: 'error', event: parsed, // isWSAPIResponse: isWSAPIResponseEvent, }); @@ -938,7 +939,7 @@ export class WebsocketClient extends BaseWebsocketClient< exception: e, eventData: event.data, }, - eventType: 'exception', + eventType: 'error', }); this.logger.error('Failed to parse event data due to exception: ', { @@ -963,43 +964,59 @@ export class WebsocketClient extends BaseWebsocketClient< /** * Send a Websocket API event on a connection. Returns a promise that resolves on reply. * - * Returned promise is rejected if an exception is detected in the reply OR the connection disconnects for any reason (even if automatic reconnect will happen). - * * Authentication is automatic. If you didn't request authentication yourself, there might be a small delay after your first request, while the SDK automatically authenticates. * + * Returned promise is rejected if: + * - an exception is detected in the reply, OR + * - the connection disconnects for any reason (even if automatic reconnect will happen). + * * If you authenticated once and you're reconnected later (e.g. connection temporarily lost), the SDK will by default automatically: * - Detect you were authenticated to the WS API before * - Try to re-authenticate (up to 5 times, in case something (bad timestamp) goes wrong) * - If it succeeds, it will emit the 'authenticated' event. - * - If it fails and gives up, it will emit an 'exception' event (type: 'wsapi.auth', reason: detailed text). + * - If it fails and gives up, it will emit an 'exception' event. * - * You can turn off the automatic re-auth WS API logic using `reauthWSAPIOnReconnect: false` in the WSClient config. - * - * @param wsKey - The connection this event is for (e.g. "spotV4" | "perpFuturesUSDTV4" | "perpFuturesBTCV4" | "deliveryFuturesUSDTV4" | "deliveryFuturesBTCV4" | "optionsV4") - * @param channel - The channel this event is for (e.g. "spot.login" to authenticate) - * @param params - Any request parameters for the payload (contents of req_param in the docs). Signature generation is automatic, only send parameters such as order ID as per the docs. + * @param wsKey - The connection this event is for. Currently only "v5PrivateTrade" is supported, since that is the dedicated WS API connection. + * @param operation - The command being sent, e.g. "order.create" to submit a new order. + * @param params - Any request parameters for the command. E.g. `OrderParamsV5` to submit a new order. Only send parameters for the request body. Everything else is automatically handled. * @returns Promise - tries to resolve with async WS API response. Rejects if disconnected or exception is seen in async WS API response */ - // This overload allows the caller to omit the 3rd param, if it isn't required (e.g. for the login call) - async sendWSAPIRequest< - TWSKey extends keyof WsAPIWsKeyTopicMap = keyof WsAPIWsKeyTopicMap, - TWSOperation extends - WsAPIWsKeyTopicMap[TWSKey] = WsAPIWsKeyTopicMap[TWSKey], - TWSParams extends - WsAPITopicRequestParamMap[TWSOperation] = WsAPITopicRequestParamMap[TWSOperation], + // This overload allows the caller to omit the 3rd param, if it isn't required + sendWSAPIRequest< + TWSKey extends keyof WsAPIWsKeyTopicMap, + TWSOperation extends WsAPIWsKeyTopicMap[TWSKey], + TWSParams extends Exact, >( wsKey: TWSKey, operation: TWSOperation, ...params: TWSParams extends undefined ? [] : [TWSParams] ): Promise; + // These overloads give stricter types than mapped generics, since generic constraints do not trigger excess property checks + // Without these overloads, TypeScript won't complain if you include an unexpected property with your request (if it doesn't clash with an existing property) + sendWSAPIRequest( + wsKey: typeof WS_KEY_MAP.v5PrivateTrade, + operation: 'order.create', + params: WsAPITopicRequestParamMap['order.create'], + ): Promise; + + sendWSAPIRequest( + wsKey: typeof WS_KEY_MAP.v5PrivateTrade, + operation: 'order.amend', + params: WsAPITopicRequestParamMap['order.amend'], + ): Promise; + + sendWSAPIRequest( + wsKey: typeof WS_KEY_MAP.v5PrivateTrade, + operation: 'order.cancel', + params: WsAPITopicRequestParamMap['order.cancel'], + ): Promise; + async sendWSAPIRequest< - TWSKey extends keyof WsAPIWsKeyTopicMap = keyof WsAPIWsKeyTopicMap, - TWSOperation extends - WsAPIWsKeyTopicMap[TWSKey] = WsAPIWsKeyTopicMap[TWSKey], - TWSParams extends - WsAPITopicRequestParamMap[TWSOperation] = WsAPITopicRequestParamMap[TWSOperation], + TWSKey extends keyof WsAPIWsKeyTopicMap, + TWSOperation extends WsAPIWsKeyTopicMap[TWSKey], + TWSParams extends Exact, TWSAPIResponse extends WsAPIOperationResponseMap[TWSOperation] = WsAPIOperationResponseMap[TWSOperation], >( From 82611aac15fc3dc6ff6754241f68f5b01470a12b Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Wed, 22 Jan 2025 16:53:27 +0000 Subject: [PATCH 14/60] feat(): add event-driven example for WS API commands --- examples/ws-api-events.ts | 159 +++++++++++++++++++++++++++++++++ src/util/websockets/WsStore.ts | 2 + 2 files changed, 161 insertions(+) create mode 100644 examples/ws-api-events.ts diff --git a/examples/ws-api-events.ts b/examples/ws-api-events.ts new file mode 100644 index 0000000..10ea17e --- /dev/null +++ b/examples/ws-api-events.ts @@ -0,0 +1,159 @@ +import { DefaultLogger, WS_KEY_MAP, WebsocketClient } from '../src'; + +// or +// import { DefaultLogger, WS_KEY_MAP, WebsocketClient } from 'bybit-api'; + +const logger = { + ...DefaultLogger, + // For a more detailed view of the WebsocketClient, enable the `trace` level by uncommenting the below line: + // trace: (...params) => console.log('trace', ...params), +}; + +const key = process.env.API_KEY; +const secret = process.env.API_SECRET; + +const testnetCreds = { + key: 'KdClhWyRVjDsnER6RW', + secret: 'rLeppmdAMZsHpdgeyFsJDjhd5mnIul3ZMet2', + testnet: true, +}; + +const wsClient = new WebsocketClient( + { + key: key, + secret: secret, + // testnet: true, // Whether to use the testnet environment: https://testnet.bybit.com/app/user/api-management + // demoTrading: false, // note: As of Jan 2025, demo trading does NOT support the WS API + ...testnetCreds, + }, + logger, // Optional: inject a custom logger +); + +/** + * General event handlers for monitoring the WebsocketClient + */ +wsClient.on('update', (data) => { + console.log('raw message received ', JSON.stringify(data)); +}); +wsClient.on('open', (data) => { + console.log('ws connected', data.wsKey); +}); +wsClient.on('reconnect', ({ wsKey }) => { + console.log('ws automatically reconnecting.... ', wsKey); +}); +wsClient.on('reconnected', (data) => { + console.log('ws has reconnected ', data?.wsKey); +}); +wsClient.on('authenticated', (data) => { + console.log('ws has authenticated ', data?.wsKey); +}); + +async function main() { + /** + * + * This SDK's WebSocket API integration is event-driven at its core. You can treat the sentWSAPIRquest(...) method as + * a fire-and-forget method, to submit commands (create/amend/cancel order) via a WebSocket Connection. + * + * Replies to commands will show in the `response` event from the WebsocketClient's EventEmitter. Exceptions, however, + * will show in the `error` event from the WebsocketClient's EventEmitter. + * + * - Fire-and-forget a command. + * - Handle command results in the `response` event handler asynchronously as desired. + * - Handle any exceptions in a catch block. + * + * This is a more "raw" workflow in how WebSockets behave. For a more convenient & REST-like approach, using the + * promise-driven interface is recommended. See the `ws-api-promises.ts` example for a demonstration you can compare. + * + * Note: even without using promises, you should still tie on a .catch handler to each sendWSAPIRequest call, to prevent + * any unnecessary "unhandled promise rejection" exceptions. + * + */ + + // To make it easier to watch, wait a few seconds before sending the amend order + const AMEND_AFTER_SECONDS = 3; + + // Then wait a few more before sending the cancel order + const CANCEL_AFTER_SECONDS = 5; + + // Exceptions including rejected commands will show here (as well as the catch handler used below) + wsClient.on('error', (data) => { + console.error('ws error: ', data); + }); + + // Replies to commands will show here + wsClient.on('response', (data) => { + console.log('received reply to command: ', JSON.stringify(data, null, 2)); + }); + + /** + * + * If you haven't connected yet, the WebsocketClient will automatically connect and authenticate you as soon as you send + * your first command. That connection will then be reused for every command you send, unless the connection drops - then + * it will automatically be replaced with a healthy connection. + * + * This "not connected yet" scenario can add an initial delay to your first command. If you want to prepare a connection + * in advance, you can ask the WebsocketClient to prepare it before you start submitting commands. This is optional. + * + * Repeated note: even without using promises, you should still tie on a .catch handler to each sendWSAPIRequest call, to prevent + * any unnecessary "unhandled promise rejection" exceptions. + * + */ + + // Optional, see above. Can be used to prepare a connection before sending commands + await wsClient.connectWSAPI(); + + console.log('Step 1: Create an order'); + + // Fire and forget the create.order command + // Even without using promises, you should still "catch" exceptions (although no need to await anything you send) + wsClient + .sendWSAPIRequest(WS_KEY_MAP.v5PrivateTrade, 'order.create', { + symbol: 'BTCUSDT', + side: 'Buy', + orderType: 'Limit', + price: '50000', + qty: '1', + category: 'linear', + }) + .catch((e) => console.error('Step 1: Order submit exception: ', e)); + + console.log('Step 1: Create order sent...'); + + // + setTimeout(() => { + console.log('Step 2: Amend an order'); + + // Fire and forget the order.amend command + // For simplicity, the orderId is hardcoded here (and will probably not work) + wsClient + .sendWSAPIRequest(WS_KEY_MAP.v5PrivateTrade, 'order.amend', { + symbol: 'BTCUSDT', + category: 'linear', + orderId: '1234567', + price: '55000', + }) + .catch((e) => console.error('Step 2: Amend order exception: ', e)); + + console.log('Step 2: Amend order sent...'); + }, AMEND_AFTER_SECONDS * 1000); + + // + setTimeout(() => { + console.log('Step 3: Cancel an order'); + + // Fire and forget the order.cancel command + // For simplicity, the orderId is hardcoded here (and will probably not work) + wsClient + .sendWSAPIRequest(WS_KEY_MAP.v5PrivateTrade, 'order.cancel', { + category: 'linear', + symbol: 'BTCUSDT', + orderId: '1234567', + }) + .catch((e) => console.error('Step 3: Cancel order exception: ', e)); + + console.log('Step 3: Cancel order sent...'); + }, CANCEL_AFTER_SECONDS * 1000); +} + +// Start executing the example workflow +main(); diff --git a/src/util/websockets/WsStore.ts b/src/util/websockets/WsStore.ts index c4d58d9..2744deb 100644 --- a/src/util/websockets/WsStore.ts +++ b/src/util/websockets/WsStore.ts @@ -203,9 +203,11 @@ export class WsStore< removeAfter: boolean, ): void { const promise = this.getDeferredPromise(wsKey, promiseRef); + if (promise?.reject) { promise.reject(value); } + if (removeAfter) { this.removeDeferredPromise(wsKey, promiseRef); } From cd37a1b6b9ee7e0a41a704e02008075952ba75cf Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Wed, 22 Jan 2025 17:21:37 +0000 Subject: [PATCH 15/60] chore(): update event-driven ws api example --- examples/ws-api-events.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/examples/ws-api-events.ts b/examples/ws-api-events.ts index 10ea17e..8e3a9c9 100644 --- a/examples/ws-api-events.ts +++ b/examples/ws-api-events.ts @@ -12,19 +12,12 @@ const logger = { const key = process.env.API_KEY; const secret = process.env.API_SECRET; -const testnetCreds = { - key: 'KdClhWyRVjDsnER6RW', - secret: 'rLeppmdAMZsHpdgeyFsJDjhd5mnIul3ZMet2', - testnet: true, -}; - const wsClient = new WebsocketClient( { key: key, secret: secret, // testnet: true, // Whether to use the testnet environment: https://testnet.bybit.com/app/user/api-management // demoTrading: false, // note: As of Jan 2025, demo trading does NOT support the WS API - ...testnetCreds, }, logger, // Optional: inject a custom logger ); From d8bd68ed249a87d05de438aca3df9b7df9d9d9a0 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Thu, 23 Jan 2025 12:37:21 +0000 Subject: [PATCH 16/60] chore(): update readme with WS API examples --- README.md | 321 ++++++++++++++++++++--------- examples/ws-private-v5.ts | 3 +- examples/ws-public-v5.ts | 3 +- src/types/websockets/ws-general.ts | 1 + src/util/BaseWSClient.ts | 9 - 5 files changed, 230 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 4a722fc..8ea7f16 100644 --- a/README.md +++ b/README.md @@ -33,17 +33,17 @@ Node.js, JavaScript & TypeScript SDK for the Bybit REST APIs and WebSockets: - Automatically handle listenKey persistence and expiration/refresh. - Emit `reconnected` event when dropped connection is restored. - WebSocket API integration, with two design patterns to choose from: - - Asynchronous event-driven responses: - - Subscribe to `response` and `error` events from WebsocketClient's event emitter. - - Send commands with the sendWSAPIRequest(...) method. - - Responses to commands will arrive via the `response` and `error` events. - - See example for more details: [examples/ws-api-events.ts](./examples/ws-api-events.ts) - Asynchronous promise-driven responses: - This behaves very much like a REST API. No need to subscribe to asynchronous events. - Send commands with the await sendWSAPIRequest(...) method. - Await responses to commands directly in the fully typed sendWSAPIRequest() call. - The method directly returns a promise. Use a try/catch block for convenient error handling without the complexity of asynchronous WebSockets. - See example for more details: [examples/ws-api-promises.ts](./examples/ws-api-promises.ts) + - Asynchronous event-driven responses: + - Subscribe to `response` and `error` events from WebsocketClient's event emitter. + - Send commands with the sendWSAPIRequest(...) method. + - Responses to commands will arrive via the `response` and `error` events. + - See example for more details: [examples/ws-api-events.ts](./examples/ws-api-events.ts) - Active community support & collaboration in telegram: [Node.js Algo Traders](https://t.me/nodetraders). ## Installation @@ -80,7 +80,7 @@ Check out my related JavaScript/TypeScript/Node.js projects: ## Documentation -Most methods accept JS objects. These can be populated using parameters specified by Bybit's API documentation, or check the type definition in each class within the github repository (see table below for convenient links to each class). +Most methods accept JS objects. These can be populated using parameters specified by Bybit's API documentation, or check the type definition in each class within the github repository (see table below for convenient links to each class). TypeScript is definitely recommended, but not required. - [Bybit API Docs](https://bybit-exchange.github.io/docs/v5/intro) - [REST Endpoint Function List](./docs/endpointFunctionList.md) @@ -88,19 +88,26 @@ Most methods accept JS objects. These can be populated using parameters specifie ## Structure -This connector is fully compatible with both TypeScript and pure JavaScript projects, while the connector is written in TypeScript. A pure JavaScript version can be built using `npm run build`, which is also the version published to [npm](https://www.npmjs.com/package/bybit-api). +The SDK is written in TypeScript, but fully compatible with both TypeScript and pure JavaScript projects. A pure JavaScript version can be built using `npm run build`. The output of the `build` command is the version published to npm, packaged as a JavaScript module (with types available for you TypeScript users). -The version on npm is the output from the `build` command and can be used in projects without TypeScript (although TypeScript is definitely recommended). -- [src](./src) - the whole connector written in TypeScript +- [src](./src) - the complete SDK written in TypeScript. - [lib](./lib) - the JavaScript version of the project (built from TypeScript). This should not be edited directly, as it will be overwritten with each release. - [examples](./examples) - examples & demonstrations. Contributions are welcome! +- [test](./test) - automated end-to-end tests that run before every release, making real API calls. --- +Examples for using each client can be found in: + +- the [examples](./examples) folder. +- the [awesome-crypto-examples](https://github.com/tiagosiebler/awesome-crypto-examples) repository. + +If you're missing an example, you're welcome to request one. Priority will be given to [github sponsors](https://github.com/sponsors/tiagosiebler). + ## REST API Clients -Bybit used to have several API groups (originally one per product). You should be using the V5 APIs. If you aren't, you should upgrade your project to use the V5 APIs as soon as possible. +You should be using the V5 APIs. If you aren't, you should upgrade your project to use the V5 APIs as soon as possible. Bybit used to have several API groups (originally one per product), but the V5 API is currently the latest standard. Refer to the [V5 interface mapping page](https://bybit-exchange.github.io/docs/v5/intro#v5-and-v3-interface-mapping-list) for more information on which V5 endpoints can be used instead of previous V3 endpoints. To learn more about the V5 API, please read the [V5 upgrade guideline](https://bybit-exchange.github.io/docs/v5/upgrade-guide). @@ -145,14 +152,6 @@ Each generation is labelled with the version number (e.g. v1/v2/v3/v5). New proj ---- - -Examples for using each client can be found in: - -- the [examples](./examples) folder. -- the [awesome-crypto-examples](https://github.com/tiagosiebler/awesome-crypto-examples) repository. - -If you're missing an example, you're welcome to request one. Priority will be given to [github sponsors](https://github.com/sponsors/tiagosiebler). ### Usage @@ -161,66 +160,72 @@ Create API credentials on Bybit's website: - [Livenet](https://bybit.com/app/user/api-management?affiliate_id=9410&language=en-US&group_id=0&group_type=1) - [Testnet](https://testnet.bybit.com/app/user/api-management) -All REST clients have can be used in a similar way. However, method names, parameters and responses may vary depending on the API category you're using! - -Not sure which function to call or which parameters to use? Click the class name in the table above to look at all the function names (they are in the same order as the official API docs), and check the API docs for a list of endpoints/parameters/responses. The following is a minimal example for using the REST clients included with this SDK. For more detailed examples, refer to the [examples](./examples/) folder in the repository on GitHub: ```typescript -const { - InverseClient, - LinearClient, - InverseFuturesClient, - SpotClientV3, - UnifiedMarginClient, - USDCOptionClient, - USDCPerpetualClient, - AccountAssetClient, - CopyTradingClient, - RestClientV5, -} = require('bybit-api'); +const { RestClientV5 } = require('bybit-api'); +// or +// import { RestClientV5 } from 'bybit-api'; const restClientOptions = { - /** Your API key. Optional, if you plan on making private api calls */ - key?: string; + /** Your API key */ + key: 'apiKeyHere', - /** Your API secret. Optional, if you plan on making private api calls */ - secret?: string; + /** Your API secret */ + secret: 'apiSecretHere', /** Set to `true` to connect to testnet. Uses the live environment by default. */ - testnet?: boolean; - - /** Override the max size of the request window (in ms) */ - recv_window?: number; - - /** Default: false. If true, we'll throw errors if any params are undefined */ - strict_param_validation?: boolean; + // testnet: true, /** - * Optionally override API protocol + domain - * e.g baseUrl: 'https://api.bytick.com' - **/ - baseUrl?: string; + * Set to `true` to use Bybit's V5 demo trading: https://bybit-exchange.github.io/docs/v5/demo + * + * Note: to use demo trading, you should have `testnet` disabled + */ + // demoTrading: true, - /** Default: true. whether to try and post-process request exceptions. */ - parse_exceptions?: boolean; + /** Override the max size of the request window (in ms) */ + // recv_window: 5000, // 5000 = 5 seconds + + /** + * Enable keep alive for REST API requests (via axios). + * See: https://github.com/tiagosiebler/bybit-api/issues/368 + */ + // keepAlive: true, + + /** + * When using HTTP KeepAlive, how often to send TCP KeepAlive packets over sockets being kept alive. Default = 1000. + * Only relevant if keepAlive is set to true. + * Default: 1000 (defaults comes from https agent) + */ + // keepAliveMsecs: 1000, // 1000 = 1 second + + /** + * Optionally override API domain used: + * apiRegion: 'default' | 'bytick' | 'NL' | 'HK' | 'TK', + **/ + + // apiRegion: 'bytick', /** Default: false. Enable to parse/include per-API/endpoint rate limits in responses. */ - parseAPIRateLimits?: boolean; + // parseAPIRateLimits: true, - /** Default: false. Enable to throw error if rate limit parser fails */ - throwOnFailedRateLimitParse?: boolean; + /** + * Allows you to provide a custom "signMessage" function, e.g. to use node's much faster createHmac method + * + * Look at examples/fasterHmacSign.ts for a demonstration: + */ + // customSignMessageFn: (message: string, secret: string) => Promise; }; const API_KEY = 'xxx'; const API_SECRET = 'yyy'; -const useTestnet = false; const client = new RestClientV5({ key: API_KEY, secret: API_SECRET, - testnet: useTestnet, + // demoTrading: true, // Optional: enable to try parsing rate limit values from responses // parseAPIRateLimits: true }, @@ -228,7 +233,7 @@ const client = new RestClientV5({ ); // For public-only API calls, simply don't provide a key & secret or set them to undefined -// const client = new RestClientV5({}); +// const client = new RestClientV5(); client.getAccountInfo() .then(result => { @@ -238,7 +243,7 @@ client.getAccountInfo() console.error("getAccountInfo error: ", err); }); -client.getOrderbook({ category: 'linear', symbol: 'BTCUSD' }) +client.getOrderbook({ category: 'linear', symbol: 'BTCUSDT' }) .then(result => { console.log("getOrderBook result: ", result); }) @@ -249,7 +254,23 @@ client.getOrderbook({ category: 'linear', symbol: 'BTCUSD' }) ## WebSockets -All API groups can be used via a shared `WebsocketClient`. However, to listen to multiple API groups at once, you will need to make one WebsocketClient instance per API group. +The WebsocketClient will automatically use the latest V5 WebSocket endpoints by default. To use a different endpoint, use the `market` parameter. Except for the WebSocket API - this can be accessed without any special configuration. + +### Topics over muliple connections + +The WebsocketClient will automatically prepare one connection per API group, for all topics in that API group. Any topics that you subscribe to on that WebSocket client will automatically be added to the same connection. To spread your subscriptions over multiple topics, e.g. to reduce the throughput of an individual connection, you can make one instance of the WebsocketClient per connection group. + +```typescript +const wsClientGroup1 = new WebsocketClient(); +const wsClientGroup2 = new WebsocketClient(); + +// Attach event listeners to each WS Client +// Divide your desired topics into separate groups +``` + +Each WebsocketClient maintains a separate state, so if you do this, it's important that you ensure you don't subscribe to the same topics on both clients, or you will receive duplicate messages. + +### Specifying other markets The WebsocketClient can be configured to a specific API group using the market parameter. These are the currently available API groups: | API Category | Market | Description | @@ -286,48 +307,71 @@ The following API groups are still available in the WebsocketClient but are depr ### WebSocket Examples +#### Consuming events + Here's a minimal example for using the websocket client. For more complete examples, look into the ws-\* examples in the [examples](./examples/) folder in the repo on GitHub. ```javascript const { WebsocketClient } = require('bybit-api'); +// or +// import { WebsocketClient } from 'bybit-api'; const API_KEY = 'xxx'; const PRIVATE_KEY = 'yyy'; const wsConfig = { - key: API_KEY, - secret: PRIVATE_KEY, + /** + * API credentials are optional. They are only required if you plan on using any account-specific topics or the WS API + */ + key: 'yourAPIKeyHere', + secret: 'yourAPISecretHere', /* The following parameters are optional: */ - // Connects to livenet by default. Set testnet to true to use the testnet environment. + /** + * The API group this client should connect to. The V5 market is currently used by default. + * + * For the V3 APIs use `v3` as the market (spot/unified margin/usdc/account asset/copy trading). Note that older API groups are deprecated and may stop working soon. + */ + // market: 'v5', + + /** + * Set to `true` to connect to Bybit's testnet environment. + * - If demo trading, `testnet` should be set to false! + * - If testing a strategy, use demo trading instead. Testnet market data is very different from real market conditions. + */ // testnet: true - // If you can, use the v5 market (the newest generation of Bybit's websockets) - market: 'v5', + /** + * Set to `true` to connect to Bybit's V5 demo trading: https://bybit-exchange.github.io/docs/v5/demo + * + * Only the "V5" "market" is supported here. + */ + // demoTrading; true; - // how long to wait (in ms) before deciding the connection should be terminated & reconnected - // pongTimeout: 1000, - - // how often to check (in ms) that WS connection is still alive - // pingInterval: 10000, - - // how long to wait before attempting to reconnect (in ms) after connection is closed - // reconnectTimeout: 500, - - // recv window size for authenticated websocket requests (higher latency connections (VPN) can cause authentication to fail if the recv window is too small) + // recv window size for websocket authentication (higher latency connections (VPN) can cause authentication to fail if the recv window is too small) // recvWindow: 5000, - // config options sent to RestClient (used for time sync). See RestClient docs. - // restOptions: { }, + /** How often to check if the connection is alive (in ms) */ + // pingInterval: 10000, - // config for axios used for HTTP requests. E.g for proxy support - // requestOptions: { } + /** How long to wait (in ms) for a pong (heartbeat reply) before assuming the connection is dead */ + // pongTimeout: 1000, + + /** Delay in milliseconds before respawning the connection */ + // reconnectTimeout: 500, // override which URL to use for websocket connections // wsUrl: 'wss://stream.bytick.com/realtime' + + /** + * Allows you to provide a custom "signMessage" function, e.g. to use node's much faster createHmac method + * + * Look at examples/fasterHmacSign.ts for a demonstration: + */ + // customSignMessageFn: (message: string, secret: string) => Promise; }; const ws = new WebsocketClient(wsConfig); @@ -335,18 +379,34 @@ const ws = new WebsocketClient(wsConfig); // (v5) subscribe to multiple topics at once ws.subscribeV5(['orderbook.50.BTCUSDT', 'orderbook.50.ETHUSDT'], 'linear'); -// (v5) and/or subscribe to individual topics on demand +// Or one at a time +ws.subscribeV5('kline.5.BTCUSDT', 'linear'); +ws.subscribeV5('kline.5.ETHUSDT', 'linear'); + +// Private/public topics can be used in the same WS client instance, even for different API groups (linear, options, spot, etc) ws.subscribeV5('position', 'linear'); ws.subscribeV5('publicTrade.BTC', 'option'); +/** + * The Websocket Client will automatically manage all connectivity & authentication for you. + * + * If a network issue occurs, it will automatically: + * - detect it, + * - remove the dead connection, + * - replace it with a new one, + * - resubscribe to everything you were subscribed to. + * + * When this happens, you will see the "reconnected" event. + */ + // Listen to events coming from websockets. This is the primary data source ws.on('update', (data) => { - console.log('update', data); + console.log('data received', JSON.stringify(data, null, 2)); }); // Optional: Listen to websocket connection open event (automatic after subscribing to one or more topics) ws.on('open', ({ wsKey, event }) => { - console.log('connection open for websocket with ID: ' + wsKey); + 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) @@ -363,8 +423,92 @@ ws.on('close', () => { ws.on('error', (err) => { console.error('error', err); }); + +ws.on('reconnect', ({ wsKey }) => { + console.log('ws automatically reconnecting.... ', wsKey); +}); + +ws.on('reconnected', (data) => { + console.log('ws has reconnected ', data?.wsKey); +}); ``` +#### Websocket API - Sending orders over WS + +Bybit supports sending, amending and cancelling orders over a WebSocket connection. Links for reference: +- [Bybit WebSocket API Documentation](https://bybit-exchange.github.io/docs/v5/websocket/trade/guideline) +- [WebSocket API Example Node.js/TypeScript/JavaScript](./examples/ws-api-promises.ts). + +Note: as of January 2024, the demo trading environment does not support the Websocket API. + +##### Usage + +The [WebsocketClient](./src/WebsocketClient.ts) supports this Bybit's Websocket API. There are two ways to use the WS API, depending on individual preference: + +- event-driven: + - send requests via `client.sendWSAPIRequest(wsKey, channel, params)`, fire and forget, don't use await + - handle async replies via event handlers on `client.on('exception', cb)` and `client.on('response', cb)` +- promise-driven: + - send requests via `const result = await client.sendWSAPIRequest(wsKey, channel, params)`, which returns a promise + - await each call + - use try/catch blocks to handle promise rejections + +The below example demonstrates the promise-driven approach, which behaves similar to a REST API. For more detailed examples, refer to the [examples](./examples/) folder (e.g the [examples/ws-api-promises.ts](./examples/ws-api-promises.ts) example). + +```javascript +const { WS_KEY_MAP, WebsocketClient } = require('bybit-api'); + +// or +// import { WS_KEY_MAP, WebsocketClient } from 'bybit-api'; + +// Create an instance of the WebsocketClient +const wsClient = new WebsocketClient( + { + key: 'yourApiKeyHere', + secret: 'yourApiSecretHere', + // testnet: true, // Whether to use the testnet environment. Create API keys here: https://testnet.bybit.com/app/user/api-management + // demoTrading: false, // note: As of Jan 2025, demo trading does NOT support the WS API + } +); + +// This example is wrapped in an async function, so "await" can be used +async function main() { + // Optional. Can be used to prepare a connection before sending commands (e.g. as part of your startup process) + // await wsClient.connectWSAPI(); + + try { + console.log('Step 1: Create an order'); + + // The type for `wsAPISubmitOrderResult` is automatically resolved to `WSAPIResponse` + const wsAPISubmitOrderResult = await wsClient.sendWSAPIRequest( + WS_KEY_MAP.v5PrivateTrade, + 'order.create', + { + symbol: 'BTCUSDT', + side: 'Buy', + orderType: 'Limit', + price: '50000', + qty: '1', + category: 'linear', + }, + ); + + console.log( + `Step 1: Order result (order ID: "${wsAPISubmitOrderResult.data.orderId}"): `, + wsAPISubmitOrderResult, + ); + } catch (e) { + console.error('Step 1: Order submit exception: ', e); + } +} + +// Start executing the example workflow +main(); + +``` + + + See [websocket-client.ts](./src/websocket-client.ts) for further information. --- @@ -381,7 +525,7 @@ const { WebsocketClient, DefaultLogger } = require('bybit-api'); // Enable all logging on the trace level (disabled by default) const customLogger = { ...DefaultLogger, - trace: (...params) => console.log('silly', ...params), + trace: (...params) => console.log('trace', ...params), }; const wsClient = new WebsocketClient({ key: 'xxx', secret: 'yyy' }, customLogger); @@ -442,23 +586,8 @@ Have my projects helped you? Share the love, there are many ways you can show yo - Or buy me all the coffee: - ETH(ERC20): `0xA3Bda8BecaB4DCdA539Dc16F9C54a592553Be06C` - -#### pixtron - -An early generation of this library was started by @pixtron. If this library helps you to trade better on bybit, feel free to donate a coffee to @pixtron: - -- BTC `1Fh1158pXXudfM6ZrPJJMR7Y5SgZUz4EdF` -- ETH `0x21aEdeC53ab7593b77C9558942f0c9E78131e8d7` -- LTC `LNdHSVtG6UWsriMYLJR3qLdfVNKwJ6GSLF` - ### Contributions & Pull Requests Contributions are encouraged, I will review any incoming pull requests. See the issues tab for todo items. diff --git a/examples/ws-private-v5.ts b/examples/ws-private-v5.ts index 868e350..e4bb3f1 100644 --- a/examples/ws-private-v5.ts +++ b/examples/ws-private-v5.ts @@ -26,7 +26,8 @@ const wsClient = new WebsocketClient( { key: key, secret: secret, - testnet: true, + // testnet: false, + // demoTrading: false, // set testnet to false, if you plan on using demo trading }, logger, ); diff --git a/examples/ws-public-v5.ts b/examples/ws-public-v5.ts index 4201ed2..6f3e7ca 100644 --- a/examples/ws-public-v5.ts +++ b/examples/ws-public-v5.ts @@ -19,7 +19,8 @@ const logger = { */ const wsClient = new WebsocketClient( { - market: 'v5', + // Previously required, this parameter is now optional: + // market: 'v5', }, logger, ); diff --git a/src/types/websockets/ws-general.ts b/src/types/websockets/ws-general.ts index 130e2c5..e156ad9 100644 --- a/src/types/websockets/ws-general.ts +++ b/src/types/websockets/ws-general.ts @@ -130,6 +130,7 @@ export interface WSClientConfigurableOptions { restOptions?: RestClientOptions; // eslint-disable-next-line @typescript-eslint/no-explicit-any requestOptions?: any; + wsUrl?: string; /** diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts index 03b3844..7a3a618 100644 --- a/src/util/BaseWSClient.ts +++ b/src/util/BaseWSClient.ts @@ -167,20 +167,11 @@ export abstract class BaseWebsocketClient< ...options, }; - this.options.restOptions = { - ...this.options.restOptions, - testnet: this.options.testnet, - }; - - // this.prepareRESTClient(); - // add default error handling so this doesn't crash node (if the user didn't set a handler) // eslint-disable-next-line @typescript-eslint/no-empty-function this.on('error', () => {}); } - // protected abstract prepareRESTClient(): void; - /** * Return true if this wsKey connection should automatically authenticate immediately after connecting */ From e786391f154026f2082130d78ce38cfc02d055a0 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Thu, 23 Jan 2025 12:43:23 +0000 Subject: [PATCH 17/60] chore(): improve readme --- README.md | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8ea7f16..6a3ecd9 100644 --- a/README.md +++ b/README.md @@ -125,10 +125,11 @@ Here are the available REST clients and the corresponding API groups described i The following API clients are for previous generation REST APIs and will be removed in the next major release. Some have already stopped working (because bybit stopped supporting them). You should use the V5 APIs for all new development. +
+ Click me to read more + Each generation is labelled with the version number (e.g. v1/v2/v3/v5). New projects & developments should use the newest available API generation (e.g. use the V5 APIs instead of V3). -
- Click me to see the list of APIs | Class | Description | | :--------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------: | @@ -256,9 +257,19 @@ client.getOrderbook({ category: 'linear', symbol: 'BTCUSDT' }) The WebsocketClient will automatically use the latest V5 WebSocket endpoints by default. To use a different endpoint, use the `market` parameter. Except for the WebSocket API - this can be accessed without any special configuration. -### Topics over muliple connections +### Specifying other markets -The WebsocketClient will automatically prepare one connection per API group, for all topics in that API group. Any topics that you subscribe to on that WebSocket client will automatically be added to the same connection. To spread your subscriptions over multiple topics, e.g. to reduce the throughput of an individual connection, you can make one instance of the WebsocketClient per connection group. +The WebsocketClient can be configured to a specific API group using the market parameter. These are the currently available API groups: +| API Category | Market | Description | +|:----------------------------: |:-------------------: |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| V5 Subscriptions | `market: 'v5'` | The [v5](https://bybit-exchange.github.io/docs/v5/ws/connect) websocket topics for all categories under one market. Use the subscribeV5 method when subscribing to v5 topics. | + + +### Balancing consumer load across multiple connections + +The WebsocketClient will automatically prepare one connection per API group, for all topics in that API group. Any topics that you subscribe to on that WebSocket client will automatically be added to the same connection. + +To spread your subscribed topics over multiple connections, e.g. to reduce the throughput of an individual connectionk, you can make one instance of the WebsocketClient per connection group. ```typescript const wsClientGroup1 = new WebsocketClient(); @@ -268,14 +279,7 @@ const wsClientGroup2 = new WebsocketClient(); // Divide your desired topics into separate groups ``` -Each WebsocketClient maintains a separate state, so if you do this, it's important that you ensure you don't subscribe to the same topics on both clients, or you will receive duplicate messages. - -### Specifying other markets - -The WebsocketClient can be configured to a specific API group using the market parameter. These are the currently available API groups: -| API Category | Market | Description | -|:----------------------------: |:-------------------: |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| V5 Subscriptions | `market: 'v5'` | The [v5](https://bybit-exchange.github.io/docs/v5/ws/connect) websocket topics for all categories under one market. Use the subscribeV5 method when subscribing to v5 topics. | +Important: do not subscribe to the same topics on both clients or you will receive duplicate messages (once per WS client). --- From d6cd453b33f340571666638dff89fd58cb30dc1c Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Thu, 23 Jan 2025 12:43:47 +0000 Subject: [PATCH 18/60] chore(): update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6a3ecd9..8c2adf7 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ The WebsocketClient can be configured to a specific API group using the market p | V5 Subscriptions | `market: 'v5'` | The [v5](https://bybit-exchange.github.io/docs/v5/ws/connect) websocket topics for all categories under one market. Use the subscribeV5 method when subscribing to v5 topics. | -### Balancing consumer load across multiple connections +### Balancing load across multiple connections The WebsocketClient will automatically prepare one connection per API group, for all topics in that API group. Any topics that you subscribe to on that WebSocket client will automatically be added to the same connection. From 1327f1c0db18a5aa0d99741b40401361cd5ec9e6 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Thu, 23 Jan 2025 12:44:44 +0000 Subject: [PATCH 19/60] chore(): rearrange sections --- README.md | 106 +++++++++++++++++++++++++++--------------------------- 1 file changed, 54 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 8c2adf7..a014360 100644 --- a/README.md +++ b/README.md @@ -257,58 +257,6 @@ client.getOrderbook({ category: 'linear', symbol: 'BTCUSDT' }) The WebsocketClient will automatically use the latest V5 WebSocket endpoints by default. To use a different endpoint, use the `market` parameter. Except for the WebSocket API - this can be accessed without any special configuration. -### Specifying other markets - -The WebsocketClient can be configured to a specific API group using the market parameter. These are the currently available API groups: -| API Category | Market | Description | -|:----------------------------: |:-------------------: |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| V5 Subscriptions | `market: 'v5'` | The [v5](https://bybit-exchange.github.io/docs/v5/ws/connect) websocket topics for all categories under one market. Use the subscribeV5 method when subscribing to v5 topics. | - - -### Balancing load across multiple connections - -The WebsocketClient will automatically prepare one connection per API group, for all topics in that API group. Any topics that you subscribe to on that WebSocket client will automatically be added to the same connection. - -To spread your subscribed topics over multiple connections, e.g. to reduce the throughput of an individual connectionk, you can make one instance of the WebsocketClient per connection group. - -```typescript -const wsClientGroup1 = new WebsocketClient(); -const wsClientGroup2 = new WebsocketClient(); - -// Attach event listeners to each WS Client -// Divide your desired topics into separate groups -``` - -Important: do not subscribe to the same topics on both clients or you will receive duplicate messages (once per WS client). - ---- - -### Older Websocket APIs - -The following API groups are still available in the WebsocketClient but are deprecated and may no longer work. They will be removed in the next major release: - -
- Click me to see the table - -| API Category | Market | Description | -| :------------------------------: | :-------------------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ~~Unified Margin - Options~~ | `market: 'unifiedOption'` | The [derivatives v3](https://bybit-exchange.github.io/docs/derivativesV3/unified_margin/#t-websocket) category for unified margin. Note: public topics only support options topics. If you need USDC/USDT perps, use `unifiedPerp` instead. | -| ~~Unified Margin - Perps~~ | `market: 'unifiedPerp'` | The [derivatives v3](https://bybit-exchange.github.io/docs/derivativesV3/unified_margin/#t-websocket) category for unified margin. Note: public topics only support USDT/USDC perpetual topics - use `unifiedOption` if you need public options topics. | -| ~~Futures v2 - Inverse Perps~~ | `market: 'inverse'` | The [inverse v2 perps](https://bybit-exchange.github.io/docs/futuresV2/inverse/#t-websocket) category. | -| ~~Futures v2 - USDT Perps~~ | `market: 'linear'` | The [USDT/linear v2 perps](https://bybit-exchange.github.io/docs/futuresV2/linear/#t-websocket) category. | -| ~~Futures v2 - Inverse Futures~~ | `market: 'inverse'` | The [inverse futures v2](https://bybit-exchange.github.io/docs/futuresV2/inverse_futures/#t-websocket) category uses the same market as inverse perps. | -| ~~Spot v3~~ | `market: 'spotv3'` | The [spot v3](https://bybit-exchange.github.io/docs/spot/v3/#t-websocket) category. | -| ~~Spot v1~~ | `market: 'spot'` | The older [spot v1](https://bybit-exchange.github.io/docs/spot/v1/#t-websocket) category. Use the `spotv3` market if possible, as the v1 category does not have automatic re-subscribe if reconnected. | -| ~~Copy Trading~~ | `market: 'linear'` | The [copy trading](https://bybit-exchange.github.io/docs/copy_trading/#t-websocket) category. Use the linear market to listen to all copy trading topics. | -| ~~USDC Perps~~ | `market: 'usdcPerp` | The [USDC perps](https://bybit-exchange.github.io/docs/usdc/perpetual/#t-websocket) category. | -| ~~USDC Options~~ | `market: 'usdcOption'` | The [USDC options](https://bybit-exchange.github.io/docs/usdc/option/#t-websocket) category. | -| ~~Contract v3 USDT~~ | `market: 'contractUSDT'` | The [Contract V3](https://bybit-exchange.github.io/docs/derivativesV3/contract/#t-websocket) category (USDT perps) | -| ~~Contract v3 Inverse~~ | `market: 'contractInverse'` | The [Contract V3](https://bybit-exchange.github.io/docs/derivativesV3/contract/#t-websocket) category (inverse perps) | - -
+ Click me to see the table + +| API Category | Market | Description | +| :------------------------------: | :-------------------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ~~Unified Margin - Options~~ | `market: 'unifiedOption'` | The [derivatives v3](https://bybit-exchange.github.io/docs/derivativesV3/unified_margin/#t-websocket) category for unified margin. Note: public topics only support options topics. If you need USDC/USDT perps, use `unifiedPerp` instead. | +| ~~Unified Margin - Perps~~ | `market: 'unifiedPerp'` | The [derivatives v3](https://bybit-exchange.github.io/docs/derivativesV3/unified_margin/#t-websocket) category for unified margin. Note: public topics only support USDT/USDC perpetual topics - use `unifiedOption` if you need public options topics. | +| ~~Futures v2 - Inverse Perps~~ | `market: 'inverse'` | The [inverse v2 perps](https://bybit-exchange.github.io/docs/futuresV2/inverse/#t-websocket) category. | +| ~~Futures v2 - USDT Perps~~ | `market: 'linear'` | The [USDT/linear v2 perps](https://bybit-exchange.github.io/docs/futuresV2/linear/#t-websocket) category. | +| ~~Futures v2 - Inverse Futures~~ | `market: 'inverse'` | The [inverse futures v2](https://bybit-exchange.github.io/docs/futuresV2/inverse_futures/#t-websocket) category uses the same market as inverse perps. | +| ~~Spot v3~~ | `market: 'spotv3'` | The [spot v3](https://bybit-exchange.github.io/docs/spot/v3/#t-websocket) category. | +| ~~Spot v1~~ | `market: 'spot'` | The older [spot v1](https://bybit-exchange.github.io/docs/spot/v1/#t-websocket) category. Use the `spotv3` market if possible, as the v1 category does not have automatic re-subscribe if reconnected. | +| ~~Copy Trading~~ | `market: 'linear'` | The [copy trading](https://bybit-exchange.github.io/docs/copy_trading/#t-websocket) category. Use the linear market to listen to all copy trading topics. | +| ~~USDC Perps~~ | `market: 'usdcPerp` | The [USDC perps](https://bybit-exchange.github.io/docs/usdc/perpetual/#t-websocket) category. | +| ~~USDC Options~~ | `market: 'usdcOption'` | The [USDC options](https://bybit-exchange.github.io/docs/usdc/option/#t-websocket) category. | +| ~~Contract v3 USDT~~ | `market: 'contractUSDT'` | The [Contract V3](https://bybit-exchange.github.io/docs/derivativesV3/contract/#t-websocket) category (USDT perps) | +| ~~Contract v3 Inverse~~ | `market: 'contractInverse'` | The [Contract V3](https://bybit-exchange.github.io/docs/derivativesV3/contract/#t-websocket) category (inverse perps) | + +
Date: Thu, 23 Jan 2025 12:45:36 +0000 Subject: [PATCH 20/60] chore(): readme update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a014360..74d1580 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,7 @@ The WebsocketClient will automatically use the latest V5 WebSocket endpoints by ### WebSocket Examples -#### Consuming events +#### Subscriptions - Consuming events Here's a minimal example for using the websocket client. For more complete examples, look into the ws-\* examples in the [examples](./examples/) folder in the repo on GitHub. From df3572502750cc36d384153273e754e3cc46a842 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Thu, 23 Jan 2025 12:48:51 +0000 Subject: [PATCH 21/60] chore(): update readme --- README.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 74d1580..2f5235b 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,7 @@ ws.on('reconnected', (data) => { }); ``` -#### Websocket API - Sending orders over WS +#### Websocket API - Sending orders via WebSockets Bybit supports sending, amending and cancelling orders over a WebSocket connection. Links for reference: - [Bybit WebSocket API Documentation](https://bybit-exchange.github.io/docs/v5/websocket/trade/guideline) @@ -398,14 +398,16 @@ Note: as of January 2024, the demo trading environment does not support the Webs The [WebsocketClient](./src/WebsocketClient.ts) supports this Bybit's Websocket API. There are two ways to use the WS API, depending on individual preference: - event-driven: - - send requests via `client.sendWSAPIRequest(wsKey, channel, params)`, fire and forget, don't use await + - send requests via `client.sendWSAPIRequest(wsKey, operation, params)`, fire and forget, don't use await - handle async replies via event handlers on `client.on('exception', cb)` and `client.on('response', cb)` - promise-driven: - - send requests via `const result = await client.sendWSAPIRequest(wsKey, channel, params)`, which returns a promise + - send requests via `const result = await client.sendWSAPIRequest(wsKey, operation, params)`, which returns a promise - await each call - use try/catch blocks to handle promise rejections -The below example demonstrates the promise-driven approach, which behaves similar to a REST API. For more detailed examples, refer to the [examples](./examples/) folder (e.g the [examples/ws-api-promises.ts](./examples/ws-api-promises.ts) example). +The below example demonstrates the promise-driven approach, which behaves similar to a REST API. The WebSocket API even accepts the same parameters as the corresponding REST API endpoints, so this approach should be compatible with existing REST implementations. + +For more detailed examples, refer to the [examples](./examples/) folder (e.g the [examples/ws-api-promises.ts](./examples/ws-api-promises.ts) example). ```javascript const { WS_KEY_MAP, WebsocketClient } = require('bybit-api'); @@ -413,7 +415,7 @@ const { WS_KEY_MAP, WebsocketClient } = require('bybit-api'); // or // import { WS_KEY_MAP, WebsocketClient } from 'bybit-api'; -// Create an instance of the WebsocketClient +// Create an instance of the WebsocketClient. This will automatically handle connectivity and authentication for you. const wsClient = new WebsocketClient( { key: 'yourApiKeyHere', @@ -459,14 +461,10 @@ main(); ``` - - -See [websocket-client.ts](./src/websocket-client.ts) for further information. +See the [examples/ws-api-promises.ts](./examples/ws-api-promises.ts) example for a more detailed explanation. --- - - ### Specifying other markets The WebsocketClient can be configured to a specific API group using the market parameter. These are the currently available API groups: From 9524c995fcf3d6a5639a356979137caadcd0cb88 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Thu, 23 Jan 2025 12:51:48 +0000 Subject: [PATCH 22/60] chore(): readme updates --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2f5235b..3c63db5 100644 --- a/README.md +++ b/README.md @@ -387,27 +387,26 @@ ws.on('reconnected', (data) => { #### Websocket API - Sending orders via WebSockets -Bybit supports sending, amending and cancelling orders over a WebSocket connection. Links for reference: +Bybit supports sending, amending and cancelling orders over a WebSocket connection. The [WebsocketClient](./src/WebsocketClient.ts) fully supports Bybit's WebSocket API. + +Links for reference: - [Bybit WebSocket API Documentation](https://bybit-exchange.github.io/docs/v5/websocket/trade/guideline) - [WebSocket API Example Node.js/TypeScript/JavaScript](./examples/ws-api-promises.ts). -Note: as of January 2024, the demo trading environment does not support the Websocket API. - -##### Usage - -The [WebsocketClient](./src/WebsocketClient.ts) supports this Bybit's Websocket API. There are two ways to use the WS API, depending on individual preference: +Note: as of January 2024, the demo trading environment does not support the WebSocket API. +There are two ways to use the WS API, depending on individual preference: - event-driven: - send requests via `client.sendWSAPIRequest(wsKey, operation, params)`, fire and forget, don't use await - handle async replies via event handlers on `client.on('exception', cb)` and `client.on('response', cb)` + - See example for more details: [examples/ws-api-events.ts](./examples/ws-api-events.ts) - promise-driven: - send requests via `const result = await client.sendWSAPIRequest(wsKey, operation, params)`, which returns a promise - await each call - use try/catch blocks to handle promise rejections + - See example for more details: [examples/ws-api-promises.ts](./examples/ws-api-promises.ts) -The below example demonstrates the promise-driven approach, which behaves similar to a REST API. The WebSocket API even accepts the same parameters as the corresponding REST API endpoints, so this approach should be compatible with existing REST implementations. - -For more detailed examples, refer to the [examples](./examples/) folder (e.g the [examples/ws-api-promises.ts](./examples/ws-api-promises.ts) example). +The below example demonstrates the promise-driven approach, which behaves similar to a REST API. The WebSocket API even accepts the same parameters as the corresponding REST API endpoints, so this approach should be compatible with existing REST implementations. Connectivity, authentication, and processing requests wrapped in promises - these are all handled automatically by the WebsocketClient without additional configuration. ```javascript const { WS_KEY_MAP, WebsocketClient } = require('bybit-api'); @@ -428,6 +427,7 @@ const wsClient = new WebsocketClient( // This example is wrapped in an async function, so "await" can be used async function main() { // Optional. Can be used to prepare a connection before sending commands (e.g. as part of your startup process) + // This is not necessary and will happen automatically when sending a command, if you aren't connected/authenticated yet // await wsClient.connectWSAPI(); try { From 592ab00fbcb1904c9d720b36a4a1ce97828653d2 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Thu, 23 Jan 2025 12:52:41 +0000 Subject: [PATCH 23/60] chore(): readme updates --- README.md | 70 +++++++++++++++++++++++++++---------------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 3c63db5..bd7041f 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Examples for using each client can be found in: If you're missing an example, you're welcome to request one. Priority will be given to [github sponsors](https://github.com/sponsors/tiagosiebler). -## REST API Clients +## API Clients You should be using the V5 APIs. If you aren't, you should upgrade your project to use the V5 APIs as soon as possible. Bybit used to have several API groups (originally one per product), but the V5 API is currently the latest standard. @@ -119,40 +119,6 @@ Here are the available REST clients and the corresponding API groups described i | [RestClientV5](src/rest-client-v5.ts) | Unified V5 all-in-one REST client for all [V5 REST APIs](https://bybit-exchange.github.io/docs/v5/intro) | | [WebsocketClient](src/websocket-client.ts) | All WebSocket Events (Public & Private for all API categories) | ---- - -### Deprecated/Obsolete APIs - -The following API clients are for previous generation REST APIs and will be removed in the next major release. Some have already stopped working (because bybit stopped supporting them). You should use the V5 APIs for all new development. - -
- Click me to read more - -Each generation is labelled with the version number (e.g. v1/v2/v3/v5). New projects & developments should use the newest available API generation (e.g. use the V5 APIs instead of V3). - - -| Class | Description | -| :--------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------: | -| [ **Derivatives v3** ] | The Derivatives v3 APIs (successor to the Futures V2 APIs) | -| [UnifiedMarginClient](src/unified-margin-client.ts) |[Derivatives (v3) Unified Margin APIs](https://bybit-exchange.github.io/docs/derivatives/unified/place-order) | -| [ContractClient](src/contract-client.ts) | [Derivatives (v3) Contract APIs](https://bybit-exchange.github.io/docs/derivatives/contract/place-order). | -| [ **Futures v2** ] | The Futures v2 APIs | -| [~~InverseClient~~](src/inverse-client.ts) | [Inverse Perpetual Futures (v2) APIs](https://bybit-exchange.github.io/docs/futuresV2/inverse/) | -| [~~LinearClient~~](src/linear-client.ts) | [USDT Perpetual Futures (v2) APIs](https://bybit-exchange.github.io/docs/futuresV2/linear/#t-introduction) | -| [~~InverseFuturesClient~~](src/inverse-futures-client.ts) | [Inverse Futures (v2) APIs](https://bybit-exchange.github.io/docs/futuresV2/inverse_futures/#t-introduction) | -| [ **Spot** ] | The spot APIs | -| [SpotClientV3](src/spot-client-v3.ts) | [Spot Market (v3) APIs](https://bybit-exchange.github.io/docs/spot/public/instrument) | -| [~~SpotClient~~](src/spot-client.ts) (deprecated, SpotClientV3 recommended) | [Spot Market (v1) APIs](https://bybit-exchange.github.io/docs/spot/v1/#t-introduction) | -| [ **USDC Contract** ] | The USDC Contract APIs | -| [USDCPerpetualClient](src/usdc-perpetual-client.ts) | [USDC Perpetual APIs](https://bybit-exchange.github.io/docs/usdc/option/?console#t-querydeliverylog) | -| [USDCOptionClient](src/usdc-option-client.ts) | [USDC Option APIs](https://bybit-exchange.github.io/docs/usdc/option/#t-introduction) | -| [~~AccountAssetClient~~](src/account-asset-client.ts) | [Account Asset V1 APIs](https://bybit-exchange.github.io/docs/account_asset/v1/#t-introduction) | -| [ **Other** ] | Other standalone API groups | -| [CopyTradingClient](src/copy-trading-client.ts) | [Copy Trading APIs](https://bybit-exchange.github.io/docs/category/copy-trade) | -| [AccountAssetClientV3](src/account-asset-client-v3.ts) | [Account Asset V3 APIs](https://bybit-exchange.github.io/docs/account-asset/internal-transfer) | - -
- ### Usage @@ -253,6 +219,40 @@ client.getOrderbook({ category: 'linear', symbol: 'BTCUSDT' }) }); ``` +--- + +### Deprecated/Obsolete REST APIs + +The following API clients are for previous generation REST APIs and will be removed in the next major release. Some have already stopped working (because bybit stopped supporting them). You should use the V5 APIs for all new development. + +
+ Click me to read more + +Each generation is labelled with the version number (e.g. v1/v2/v3/v5). New projects & developments should use the newest available API generation (e.g. use the V5 APIs instead of V3). + + +| Class | Description | +| :--------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------: | +| [ **Derivatives v3** ] | The Derivatives v3 APIs (successor to the Futures V2 APIs) | +| [UnifiedMarginClient](src/unified-margin-client.ts) |[Derivatives (v3) Unified Margin APIs](https://bybit-exchange.github.io/docs/derivatives/unified/place-order) | +| [ContractClient](src/contract-client.ts) | [Derivatives (v3) Contract APIs](https://bybit-exchange.github.io/docs/derivatives/contract/place-order). | +| [ **Futures v2** ] | The Futures v2 APIs | +| [~~InverseClient~~](src/inverse-client.ts) | [Inverse Perpetual Futures (v2) APIs](https://bybit-exchange.github.io/docs/futuresV2/inverse/) | +| [~~LinearClient~~](src/linear-client.ts) | [USDT Perpetual Futures (v2) APIs](https://bybit-exchange.github.io/docs/futuresV2/linear/#t-introduction) | +| [~~InverseFuturesClient~~](src/inverse-futures-client.ts) | [Inverse Futures (v2) APIs](https://bybit-exchange.github.io/docs/futuresV2/inverse_futures/#t-introduction) | +| [ **Spot** ] | The spot APIs | +| [SpotClientV3](src/spot-client-v3.ts) | [Spot Market (v3) APIs](https://bybit-exchange.github.io/docs/spot/public/instrument) | +| [~~SpotClient~~](src/spot-client.ts) (deprecated, SpotClientV3 recommended) | [Spot Market (v1) APIs](https://bybit-exchange.github.io/docs/spot/v1/#t-introduction) | +| [ **USDC Contract** ] | The USDC Contract APIs | +| [USDCPerpetualClient](src/usdc-perpetual-client.ts) | [USDC Perpetual APIs](https://bybit-exchange.github.io/docs/usdc/option/?console#t-querydeliverylog) | +| [USDCOptionClient](src/usdc-option-client.ts) | [USDC Option APIs](https://bybit-exchange.github.io/docs/usdc/option/#t-introduction) | +| [~~AccountAssetClient~~](src/account-asset-client.ts) | [Account Asset V1 APIs](https://bybit-exchange.github.io/docs/account_asset/v1/#t-introduction) | +| [ **Other** ] | Other standalone API groups | +| [CopyTradingClient](src/copy-trading-client.ts) | [Copy Trading APIs](https://bybit-exchange.github.io/docs/category/copy-trade) | +| [AccountAssetClientV3](src/account-asset-client-v3.ts) | [Account Asset V3 APIs](https://bybit-exchange.github.io/docs/account-asset/internal-transfer) | + +
+ ## WebSockets The WebsocketClient will automatically use the latest V5 WebSocket endpoints by default. To use a different endpoint, use the `market` parameter. Except for the WebSocket API - this can be accessed without any special configuration. From 76b3130324d2858a89ccb8196a7b4567c92e52d8 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Thu, 23 Jan 2025 15:50:28 +0000 Subject: [PATCH 24/60] chore(): readme tweaks --- README.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bd7041f..c8cf860 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ const restClientOptions = { // keepAlive: true, /** - * When using HTTP KeepAlive, how often to send TCP KeepAlive packets over sockets being kept alive. Default = 1000. + * When using HTTP KeepAlive, how often to send TCP KeepAlive packets over sockets being kept alive. * Only relevant if keepAlive is set to true. * Default: 1000 (defaults comes from https agent) */ @@ -179,7 +179,8 @@ const restClientOptions = { // parseAPIRateLimits: true, /** - * Allows you to provide a custom "signMessage" function, e.g. to use node's much faster createHmac method + * Allows you to provide a custom "signMessage" function, + * e.g. to use node crypto's much faster createHmac method * * Look at examples/fasterHmacSign.ts for a demonstration: */ @@ -285,7 +286,8 @@ const wsConfig = { /** * The API group this client should connect to. The V5 market is currently used by default. * - * For the V3 APIs use `v3` as the market (spot/unified margin/usdc/account asset/copy trading). Note that older API groups are deprecated and may stop working soon. + * For the V3 APIs use `v3` as the market (spot/unified margin/usdc/account asset/copy trading). + * Note that older API groups are deprecated and may stop working soon. */ // market: 'v5', @@ -426,14 +428,20 @@ const wsClient = new WebsocketClient( // This example is wrapped in an async function, so "await" can be used async function main() { - // Optional. Can be used to prepare a connection before sending commands (e.g. as part of your startup process) - // This is not necessary and will happen automatically when sending a command, if you aren't connected/authenticated yet + /** + * Optional. Can be used to prepare a connection before sending + * commands (e.g. as part of your startup process). + * + * This is not necessary and will happen automatically when + * sending a command, if you aren't connected/authenticated yet. + */ // await wsClient.connectWSAPI(); try { console.log('Step 1: Create an order'); - // The type for `wsAPISubmitOrderResult` is automatically resolved to `WSAPIResponse` + // The type for `wsAPISubmitOrderResult` is automatically + // resolved to `WSAPIResponse` const wsAPISubmitOrderResult = await wsClient.sendWSAPIRequest( WS_KEY_MAP.v5PrivateTrade, 'order.create', From 7ebad90e7f7d6ea2a5ed0baaa56fb3d0385feb1b Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Thu, 23 Jan 2025 16:46:15 +0000 Subject: [PATCH 25/60] chore(): update readme --- README.md | 62 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c8cf860..7e23d64 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,8 @@ const restClientOptions = { // testnet: true, /** - * Set to `true` to use Bybit's V5 demo trading: https://bybit-exchange.github.io/docs/v5/demo + * Set to `true` to use Bybit's V5 demo trading: + * https://bybit-exchange.github.io/docs/v5/demo * * Note: to use demo trading, you should have `testnet` disabled */ @@ -162,8 +163,8 @@ const restClientOptions = { // keepAlive: true, /** - * When using HTTP KeepAlive, how often to send TCP KeepAlive packets over sockets being kept alive. - * Only relevant if keepAlive is set to true. + * When using HTTP KeepAlive, how often to send TCP KeepAlive packets over + * sockets being kept alive. Only relevant if keepAlive is set to true. * Default: 1000 (defaults comes from https agent) */ // keepAliveMsecs: 1000, // 1000 = 1 second @@ -258,9 +259,7 @@ Each generation is labelled with the version number (e.g. v1/v2/v3/v5). New proj The WebsocketClient will automatically use the latest V5 WebSocket endpoints by default. To use a different endpoint, use the `market` parameter. Except for the WebSocket API - this can be accessed without any special configuration. -### WebSocket Examples - -#### Subscriptions - Consuming events +## WebSocket Subscriptions - Consuming events Here's a minimal example for using the websocket client. For more complete examples, look into the ws-\* examples in the [examples](./examples/) folder in the repo on GitHub. @@ -274,7 +273,8 @@ const PRIVATE_KEY = 'yyy'; const wsConfig = { /** - * API credentials are optional. They are only required if you plan on using any account-specific topics or the WS API + * API credentials are optional. They are only required if you plan on using + * any account-specific topics or the WS API */ key: 'yourAPIKeyHere', secret: 'yourAPISecretHere', @@ -286,7 +286,8 @@ const wsConfig = { /** * The API group this client should connect to. The V5 market is currently used by default. * - * For the V3 APIs use `v3` as the market (spot/unified margin/usdc/account asset/copy trading). + * For the V3 APIs use `v3` as the market (spot/unified margin/usdc/account + * asset/copy trading). * Note that older API groups are deprecated and may stop working soon. */ // market: 'v5', @@ -294,24 +295,30 @@ const wsConfig = { /** * Set to `true` to connect to Bybit's testnet environment. * - If demo trading, `testnet` should be set to false! - * - If testing a strategy, use demo trading instead. Testnet market data is very different from real market conditions. + * - If testing a strategy, use demo trading instead. Testnet market + * data is very different from real market conditions. */ // testnet: true /** - * Set to `true` to connect to Bybit's V5 demo trading: https://bybit-exchange.github.io/docs/v5/demo + * Set to `true` to connect to Bybit's V5 demo trading: + * https://bybit-exchange.github.io/docs/v5/demo * * Only the "V5" "market" is supported here. */ // demoTrading; true; - // recv window size for websocket authentication (higher latency connections (VPN) can cause authentication to fail if the recv window is too small) + // recv window size for websocket authentication (higher latency connections + // (VPN) can cause authentication to fail if the recv window is too small) // recvWindow: 5000, /** How often to check if the connection is alive (in ms) */ // pingInterval: 10000, - /** How long to wait (in ms) for a pong (heartbeat reply) before assuming the connection is dead */ + /** + * How long to wait (in ms) for a pong (heartbeat reply) before assuming the + * connection is dead + */ // pongTimeout: 1000, /** Delay in milliseconds before respawning the connection */ @@ -321,7 +328,8 @@ const wsConfig = { // wsUrl: 'wss://stream.bytick.com/realtime' /** - * Allows you to provide a custom "signMessage" function, e.g. to use node's much faster createHmac method + * Allows you to provide a custom "signMessage" function, e.g. to use node's + * much faster createHmac method * * Look at examples/fasterHmacSign.ts for a demonstration: */ @@ -337,7 +345,8 @@ ws.subscribeV5(['orderbook.50.BTCUSDT', 'orderbook.50.ETHUSDT'], 'linear'); ws.subscribeV5('kline.5.BTCUSDT', 'linear'); ws.subscribeV5('kline.5.ETHUSDT', 'linear'); -// Private/public topics can be used in the same WS client instance, even for different API groups (linear, options, spot, etc) +// Private/public topics can be used in the same WS client instance, even for +// different API groups (linear, options, spot, etc) ws.subscribeV5('position', 'linear'); ws.subscribeV5('publicTrade.BTC', 'option'); @@ -358,17 +367,20 @@ ws.on('update', (data) => { console.log('data received', JSON.stringify(data, null, 2)); }); -// Optional: Listen to websocket connection open event (automatic after subscribing to one or more topics) +// Optional: Listen to websocket connection open event +// (automatic after subscribing to one or more topics) ws.on('open', ({ wsKey, event }) => { console.log('connection open for websocket with ID: ', wsKey); }); -// Optional: Listen to responses to websocket queries (e.g. the response after subscribing to a topic) +// Optional: Listen to responses to websocket queries +// (e.g. the response after subscribing to a topic) ws.on('response', (response) => { console.log('response', response); }); -// Optional: Listen to connection close event. Unexpected connection closes are automatically reconnected. +// Optional: Listen to connection close event. +// Unexpected connection closes are automatically reconnected. ws.on('close', () => { console.log('connection closed'); }); @@ -387,7 +399,7 @@ ws.on('reconnected', (data) => { }); ``` -#### Websocket API - Sending orders via WebSockets +## Websocket API - Sending orders via WebSockets Bybit supports sending, amending and cancelling orders over a WebSocket connection. The [WebsocketClient](./src/WebsocketClient.ts) fully supports Bybit's WebSocket API. @@ -416,13 +428,21 @@ const { WS_KEY_MAP, WebsocketClient } = require('bybit-api'); // or // import { WS_KEY_MAP, WebsocketClient } from 'bybit-api'; -// Create an instance of the WebsocketClient. This will automatically handle connectivity and authentication for you. +// Create an instance of the WebsocketClient. +// This will automatically handle connectivity and authentication for you. const wsClient = new WebsocketClient( { key: 'yourApiKeyHere', secret: 'yourApiSecretHere', - // testnet: true, // Whether to use the testnet environment. Create API keys here: https://testnet.bybit.com/app/user/api-management - // demoTrading: false, // note: As of Jan 2025, demo trading does NOT support the WS API + + // Whether to use the testnet environment. + // Create testnet API keys here: https://testnet.bybit.com/app/user/api-management + // testnet: true, + + // Whether to use the livenet demo trading environment + // Note: As of Jan 2025, demo trading only supports consuming events, it does + // NOT support the WS API. + // demoTrading: false, } ); From 9c12727b9d340497436d67a265bdaae41a8babbc Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Thu, 23 Jan 2025 16:53:12 +0000 Subject: [PATCH 26/60] chore(): add table of contents --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 7e23d64..598433c 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,40 @@ Node.js, JavaScript & TypeScript SDK for the Bybit REST APIs and WebSockets: - See example for more details: [examples/ws-api-events.ts](./examples/ws-api-events.ts) - Active community support & collaboration in telegram: [Node.js Algo Traders](https://t.me/nodetraders). +# Table of Contents + +## Overview +- [Installation](#installation) +- [Issues & Discussion](#issues--discussion) +- [Related Projects](#related-projects) +- [Documentation](#documentation) + +## Structure & Usage +- [Structure](#structure) +- [API Clients](#api-clients) +- [Usage](#usage) + +## WebSocket Integration +- [WebSockets](#websockets) +- [WebSocket Subscriptions - Consuming Events](#websocket-subscriptions---consuming-events) +- [Websocket API - Sending Orders via WebSockets](#websocket-api---sending-orders-via-websockets) +- [Specifying Other Markets](#specifying-other-markets) +- [Load Balancing](#balancing-load-across-multiple-connections) +- [Older Websocket APIs](#older-websocket-apis) + +## Additional Features +- [Logging](#logging) + - [Customise Logging](#customise-logging) + - [Debug HTTP Requests](#debug-http-requests) +- [Browser Usage](#browser-usage) + - [Import](#import) + - [Webpack](#webpack) + +## Contributing +- [Contributions & Thanks](#contributions--thanks) + +------ + ## Installation `npm install --save bybit-api` From ee23e137101d6de939d406623b472a3bd800789f Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Fri, 24 Jan 2025 12:44:01 +0000 Subject: [PATCH 27/60] chore(): ws client tidying and misc improvements --- src/util/BaseWSClient.ts | 9 +- src/util/websockets/websocket-util.ts | 41 +++ src/websocket-client.ts | 354 ++++++++------------------ test/ws.util.ts | 27 +- 4 files changed, 166 insertions(+), 265 deletions(-) diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts index 7a3a618..43cd23e 100644 --- a/src/util/BaseWSClient.ts +++ b/src/util/BaseWSClient.ts @@ -908,7 +908,11 @@ export abstract class BaseWebsocketClient< /** * Try sending a string event on a WS connection (identified by the WS Key) */ - public tryWsSend(wsKey: TWSKey, wsMessage: string) { + public tryWsSend( + wsKey: TWSKey, + wsMessage: string, + throwExceptions?: boolean, + ) { try { this.logger.trace('Sending upstream ws message: ', { ...WS_LOGGER_CATEGORY, @@ -934,6 +938,9 @@ export abstract class BaseWebsocketClient< wsKey, exception: e, }); + if (throwExceptions) { + throw e; + } } } diff --git a/src/util/websockets/websocket-util.ts b/src/util/websockets/websocket-util.ts index 6317a1f..1454a31 100644 --- a/src/util/websockets/websocket-util.ts +++ b/src/util/websockets/websocket-util.ts @@ -4,6 +4,7 @@ import { CategoryV5, WebsocketClientOptions, WsKey, + WsTopic, } from '../../types'; import { DefaultLogger } from '../logger'; @@ -660,3 +661,43 @@ export function getNormalisedTopicRequests( } return normalisedTopicRequests; } + +/** + * Groups topics in request into per-wsKey groups + * @param normalisedTopicRequests + * @param wsKey + * @param isPrivateTopic + * @returns + */ +export function getTopicsPerWSKey( + normalisedTopicRequests: WsTopicRequest[], + wsKey?: WsKey, + isPrivateTopic?: boolean, +): { + [key in WsKey]?: WsTopicRequest[]; +} { + const perWsKeyTopics: { [key in WsKey]?: WsTopicRequest[] } = {}; + + // Sort into per wsKey arrays, in case topics are mixed together for different wsKeys + for (const topicRequest of normalisedTopicRequests) { + const derivedWsKey = + wsKey || + getWsKeyForTopic( + this.options.market, + topicRequest.topic, + isPrivateTopic, + topicRequest.category, + ); + + if ( + !perWsKeyTopics[derivedWsKey] || + !Array.isArray(perWsKeyTopics[derivedWsKey]) + ) { + perWsKeyTopics[derivedWsKey] = []; + } + + perWsKeyTopics[derivedWsKey]!.push(topicRequest); + } + + return perWsKeyTopics; +} diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 5e70b24..07d1288 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -1,6 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ -/* eslint-disable max-len */ -/* eslint-disable @typescript-eslint/no-explicit-any */ import WebSocket from 'isomorphic-ws'; import { @@ -19,6 +16,7 @@ import { getMaxTopicsPerSubscribeEvent, getNormalisedTopicRequests, getPromiseRefForWSAPIRequest, + getTopicsPerWSKey, getWsKeyForTopic, getWsUrl, isPrivateWsTopic, @@ -35,6 +33,7 @@ import { } from './util/BaseWSClient'; import { Exact, + WSAPIOperation, WSAPIRequest, WsAPIOperationResponseMap, WsAPITopicRequestParamMap, @@ -46,47 +45,6 @@ import { SignAlgorithm, signMessage } from './util/webCryptoAPI'; const WS_LOGGER_CATEGORY = { category: 'bybit-ws' }; -/** - * Groups topics in request into per-wsKey groups - * @param normalisedTopicRequests - * @param wsKey - * @param isPrivateTopic - * @returns - */ -function getTopicsPerWSKey( - normalisedTopicRequests: WsTopicRequest[], - wsKey?: WsKey, - isPrivateTopic?: boolean, -): { - [key in WsKey]?: WsTopicRequest[]; -} { - const perWsKeyTopics: { [key in WsKey]?: WsTopicRequest[] } = {}; - - // Sort into per wsKey arrays, in case topics are mixed together for different wsKeys - for (const topicRequest of normalisedTopicRequests) { - const derivedWsKey = - wsKey || - getWsKeyForTopic( - this.options.market, - topicRequest.topic, - isPrivateTopic, - topicRequest.category, - ); - - if ( - !perWsKeyTopics[derivedWsKey] || - !Array.isArray(perWsKeyTopics[derivedWsKey]) - ) { - perWsKeyTopics[derivedWsKey] = []; - } - - perWsKeyTopics[derivedWsKey]!.push(topicRequest); - } - - return perWsKeyTopics; -} - -// export class WebsocketClient extends EventEmitter { export class WebsocketClient extends BaseWebsocketClient< WsKey, WsRequestOperationBybit @@ -213,7 +171,6 @@ export class WebsocketClient extends BaseWebsocketClient< } /** - * * Subscribe to V5 topics & track/persist them. * @param wsTopics - topic or list of topics * @param category - the API category this topic is for (e.g. "linear"). The value is only important when connecting to public topics and will be ignored for private topics. @@ -338,6 +295,8 @@ export class WebsocketClient extends BaseWebsocketClient< } /** + * Note: subscribeV5() might be simpler to use. The end result is the same. + * * Request subscription to one or more topics. Pass topics as either an array of strings, or array of objects (if the topic has parameters). * Objects should be formatted as {topic: string, params: object, category: CategoryV5}. * @@ -368,6 +327,7 @@ export class WebsocketClient extends BaseWebsocketClient< } /** + * Note: unsubscribe() might be simpler to use. The end result is the same. * Unsubscribe from one or more topics. Similar to subscribe() but in reverse. * * - Requests are automatically routed to the correct websocket connection. @@ -393,108 +353,119 @@ export class WebsocketClient extends BaseWebsocketClient< } } - /******* - * - * - * - * - * OLD WS CLIENT BELOW + /** * * * + * WS API Methods - similar to the REST API, but via WebSockets + * https://bybit-exchange.github.io/docs/v5/websocket/trade/guideline * * * */ /** - * Subscribe to V1-V3 topics & track/persist them. + * Send a Websocket API command/request on a connection. Returns a promise that resolves on reply. * - * @deprecated The V1-V3 websockets are very old and may not work properly anymore. Support for them will be removed soon. Use subcribeV5/unsubscribeV5 or subscribe/unsubscribe instead. + * WS API Documentation for list of operations and parameters: + * https://bybit-exchange.github.io/docs/v5/websocket/trade/guideline * - * Note: for public V5 topics use the `subscribeV5()` method. + * Returned promise is rejected if: + * - an exception is detected in the reply, OR + * - the connection disconnects for any reason (even if automatic reconnect will happen). * - * Topics will be automatically resubscribed to if the connection resets/drops/reconnects. - * @param wsTopics - topic or list of topics - * @param isPrivateTopic optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) + * Authentication is automatic. If you didn't request authentication yourself, there might be a small delay after your first request, while the SDK automatically authenticates. + * + * @param wsKey - The connection this event is for. Currently only "v5PrivateTrade" is supported for Bybit, since that is the dedicated WS API connection. + * @param operation - The command being sent, e.g. "order.create" to submit a new order. + * @param params - Any request parameters for the command. E.g. `OrderParamsV5` to submit a new order. Only send parameters for the request body. Everything else is automatically handled. + * @returns Promise - tries to resolve with async WS API response. Rejects if disconnected or exception is seen in async WS API response */ - public subscribeV3( - wsTopics: WsTopic[] | WsTopic, - isPrivateTopic?: boolean, - ): Promise[] { - const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; - if (this.options.market === 'v5') { - topics.forEach((topic) => { - if (!isPrivateWsTopic(topic)) { - throw new Error( - 'For public "v5" websocket topics, use the subscribeV5() method & provide the category parameter', - ); - } - }); - } - const promises: Promise[] = []; + // This overload allows the caller to omit the 3rd param, if it isn't required + sendWSAPIRequest< + TWSKey extends keyof WsAPIWsKeyTopicMap, + TWSOperation extends WsAPIWsKeyTopicMap[TWSKey], + TWSParams extends Exact, + >( + wsKey: TWSKey, + operation: TWSOperation, + ...params: TWSParams extends undefined ? [] : [TWSParams] + ): Promise; - topics.forEach((topic) => { - const wsKey = getWsKeyForTopic( - this.options.market, - topic, - isPrivateTopic, + // These overloads give stricter types than mapped generics, since generic constraints do not trigger excess property checks + // Without these overloads, TypeScript won't complain if you include an unexpected property with your request (if it doesn't clash with an existing property) + sendWSAPIRequest( + wsKey: typeof WS_KEY_MAP.v5PrivateTrade, + operation: TWSOpreation, + params: WsAPITopicRequestParamMap[TWSOpreation], + ): Promise; + + sendWSAPIRequest( + wsKey: typeof WS_KEY_MAP.v5PrivateTrade, + operation: TWSOpreation, + params: WsAPITopicRequestParamMap[TWSOpreation], + ): Promise; + + sendWSAPIRequest( + wsKey: typeof WS_KEY_MAP.v5PrivateTrade, + operation: TWSOpreation, + params: WsAPITopicRequestParamMap[TWSOpreation], + ): Promise; + + async sendWSAPIRequest< + TWSKey extends keyof WsAPIWsKeyTopicMap, + TWSOperation extends WsAPIWsKeyTopicMap[TWSKey], + TWSParams extends Exact, + TWSAPIResponse extends + WsAPIOperationResponseMap[TWSOperation] = WsAPIOperationResponseMap[TWSOperation], + >( + wsKey: WsKey = WS_KEY_MAP.v5PrivateTrade, + operation: TWSOperation, + params: TWSParams, + ): Promise { + this.logger.trace(`sendWSAPIRequest(): assert "${wsKey}" is connected`); + await this.assertIsConnected(wsKey); + this.logger.trace('sendWSAPIRequest()->assertIsConnected() ok'); + + await this.assertIsAuthenticated(wsKey); + this.logger.trace('sendWSAPIRequest()->assertIsAuthenticated() ok'); + + const requestEvent: WSAPIRequest = { + reqId: this.getNewRequestId(), + header: { + 'X-BAPI-RECV-WINDOW': `${this.options.recvWindow}`, + 'X-BAPI-TIMESTAMP': `${Date.now()}`, + Referer: APIID, + }, + op: operation, + args: [params], + }; + + // Sign, if needed + const signedEvent = await this.signWSAPIRequest(requestEvent); + + // Store deferred promise, resolved within the "resolveEmittableEvents" method while parsing incoming events + const promiseRef = getPromiseRefForWSAPIRequest(requestEvent); + + const deferredPromise = + this.getWsStore().createDeferredPromise( + wsKey, + promiseRef, + false, ); - const wsRequest: WsTopicRequest = { - topic: topic, - }; + this.logger.trace( + `sendWSAPIRequest(): sending raw request: ${JSON.stringify(signedEvent, null, 2)}`, + ); - // Persist topic for reconnects - const requestPromise = this.subscribeTopicsForWsKey([wsRequest], wsKey); + // Send event + this.tryWsSend(wsKey, JSON.stringify(signedEvent)); - promises.push(requestPromise); - }); + this.logger.trace(`sendWSAPIRequest(): sent ${operation} event`); - // Return promise to resolve midflight WS request (only works if already connected before request) - return promises; - } - - /** - * Unsubscribe from V1-V3 topics & remove them from memory. They won't be re-subscribed to if the connection reconnects. - * - * @deprecated The V1-V3 websockets are very old and may not work properly anymore. Support for them will be removed soon. Use subcribeV5/unsubscribeV5 or subscribe/unsubscribe instead. - * - * Note: For public V5 topics, use `unsubscribeV5()` instead! - * - * @param wsTopics topic or list of topics - * @param isPrivateTopic optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) - */ - public unsubscribeV3( - wsTopics: WsTopic[] | WsTopic, - isPrivateTopic?: boolean, - ) { - const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; - if (this.options.market === 'v5') { - topics.forEach((topic) => { - if (!isPrivateWsTopic(topic)) { - throw new Error( - 'For public "v5" websocket topics, use the unsubscribeV5() method & provide the category parameter', - ); - } - }); - } - - topics.forEach((topic) => { - const wsKey = getWsKeyForTopic( - this.options.market, - topic, - isPrivateTopic, - ); - - const wsRequest: WsTopicRequest = { - topic: topic, - }; - - // Persist topic for reconnects - this.unsubscribeTopicsForWsKey([wsRequest], wsKey); - }); + // Return deferred promise, so caller can await this call + return deferredPromise.promise!; } /** @@ -540,7 +511,9 @@ export class WebsocketClient extends BaseWebsocketClient< return await signMessage(paramsStr, secret, method, algorithm); } - protected async getWsAuthRequestEvent(wsKey: WsKey): Promise { + protected async getWsAuthRequestEvent( + wsKey: WsKey, + ): Promise> { try { const { signature, expiresAt } = await this.getWsAuthSignature(wsKey); @@ -656,6 +629,7 @@ export class WebsocketClient extends BaseWebsocketClient< if (wsRequestBuildingErrors.length) { const label = wsRequestBuildingErrors.length === requests.length ? 'all' : 'some'; + this.logger.error( `Failed to build/send ${wsRequestBuildingErrors.length} event(s) for ${label} WS requests due to exceptions`, { @@ -707,6 +681,7 @@ export class WebsocketClient extends BaseWebsocketClient< return isPrivateWsTopic(topicName); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any protected isWsPing(msg: any): boolean { if (!msg) { return false; @@ -726,6 +701,7 @@ export class WebsocketClient extends BaseWebsocketClient< return false; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any protected isWsPong(msg: any): boolean { if (!msg) { return false; @@ -763,7 +739,6 @@ export class WebsocketClient extends BaseWebsocketClient< event: MessageEventLike, ): EmittableEvent[] { const results: EmittableEvent[] = []; - // const isWSAPIResponseEvent = wsKey === WS_KEY_MAP.v5PrivateTrade; try { const parsed = JSON.parse(event.data); @@ -879,7 +854,6 @@ export class WebsocketClient extends BaseWebsocketClient< results.push({ eventType: 'update', event: parsed, - // isWSAPIResponse: isWSAPIResponseEvent, }); return results; } @@ -891,7 +865,6 @@ export class WebsocketClient extends BaseWebsocketClient< results.push({ eventType: 'error', event: parsed, - // isWSAPIResponse: isWSAPIResponseEvent, }); return results; } @@ -901,7 +874,6 @@ export class WebsocketClient extends BaseWebsocketClient< results.push({ eventType: 'response', event: parsed, - // isWSAPIResponse: isWSAPIResponseEvent, }); return results; } @@ -911,7 +883,6 @@ export class WebsocketClient extends BaseWebsocketClient< results.push({ eventType: 'authenticated', event: parsed, - // isWSAPIResponse: isWSAPIResponseEvent, }); return results; } @@ -950,121 +921,4 @@ export class WebsocketClient extends BaseWebsocketClient< return results; } - - /** - * - * - * - * WS API Methods - similar to the REST API, but via WebSockets - * - * - * - */ - - /** - * Send a Websocket API event on a connection. Returns a promise that resolves on reply. - * - * Authentication is automatic. If you didn't request authentication yourself, there might be a small delay after your first request, while the SDK automatically authenticates. - * - * Returned promise is rejected if: - * - an exception is detected in the reply, OR - * - the connection disconnects for any reason (even if automatic reconnect will happen). - * - * If you authenticated once and you're reconnected later (e.g. connection temporarily lost), the SDK will by default automatically: - * - Detect you were authenticated to the WS API before - * - Try to re-authenticate (up to 5 times, in case something (bad timestamp) goes wrong) - * - If it succeeds, it will emit the 'authenticated' event. - * - If it fails and gives up, it will emit an 'exception' event. - * - * @param wsKey - The connection this event is for. Currently only "v5PrivateTrade" is supported, since that is the dedicated WS API connection. - * @param operation - The command being sent, e.g. "order.create" to submit a new order. - * @param params - Any request parameters for the command. E.g. `OrderParamsV5` to submit a new order. Only send parameters for the request body. Everything else is automatically handled. - * @returns Promise - tries to resolve with async WS API response. Rejects if disconnected or exception is seen in async WS API response - */ - - // This overload allows the caller to omit the 3rd param, if it isn't required - sendWSAPIRequest< - TWSKey extends keyof WsAPIWsKeyTopicMap, - TWSOperation extends WsAPIWsKeyTopicMap[TWSKey], - TWSParams extends Exact, - >( - wsKey: TWSKey, - operation: TWSOperation, - ...params: TWSParams extends undefined ? [] : [TWSParams] - ): Promise; - - // These overloads give stricter types than mapped generics, since generic constraints do not trigger excess property checks - // Without these overloads, TypeScript won't complain if you include an unexpected property with your request (if it doesn't clash with an existing property) - sendWSAPIRequest( - wsKey: typeof WS_KEY_MAP.v5PrivateTrade, - operation: 'order.create', - params: WsAPITopicRequestParamMap['order.create'], - ): Promise; - - sendWSAPIRequest( - wsKey: typeof WS_KEY_MAP.v5PrivateTrade, - operation: 'order.amend', - params: WsAPITopicRequestParamMap['order.amend'], - ): Promise; - - sendWSAPIRequest( - wsKey: typeof WS_KEY_MAP.v5PrivateTrade, - operation: 'order.cancel', - params: WsAPITopicRequestParamMap['order.cancel'], - ): Promise; - - async sendWSAPIRequest< - TWSKey extends keyof WsAPIWsKeyTopicMap, - TWSOperation extends WsAPIWsKeyTopicMap[TWSKey], - TWSParams extends Exact, - TWSAPIResponse extends - WsAPIOperationResponseMap[TWSOperation] = WsAPIOperationResponseMap[TWSOperation], - >( - wsKey: WsKey = WS_KEY_MAP.v5PrivateTrade, - operation: TWSOperation, - params: TWSParams, - ): Promise { - this.logger.trace(`sendWSAPIRequest(): assert "${wsKey}" is connected`); - await this.assertIsConnected(wsKey); - this.logger.trace('sendWSAPIRequest()->assertIsConnected() ok'); - - await this.assertIsAuthenticated(wsKey); - this.logger.trace('sendWSAPIRequest()->assertIsAuthenticated() ok'); - - const requestEvent: WSAPIRequest = { - reqId: this.getNewRequestId(), - header: { - 'X-BAPI-RECV-WINDOW': `${this.options.recvWindow}`, - 'X-BAPI-TIMESTAMP': `${Date.now()}`, - Referer: APIID, - }, - op: operation, - args: [params], - }; - - // Sign, if needed - const signedEvent = await this.signWSAPIRequest(requestEvent); - - // Store deferred promise, resolved within the "resolveEmittableEvents" method while parsing incoming events - const promiseRef = getPromiseRefForWSAPIRequest(requestEvent); - - const deferredPromise = - this.getWsStore().createDeferredPromise( - wsKey, - promiseRef, - false, - ); - - this.logger.trace( - `sendWSAPIRequest(): sending raw request: ${JSON.stringify(signedEvent, null, 2)}`, - ); - - // Send event - this.tryWsSend(wsKey, JSON.stringify(signedEvent)); - - this.logger.trace(`sendWSAPIRequest(): sent ${operation} event`); - - // Return deferred promise, so caller can await this call - return deferredPromise.promise!; - } } diff --git a/test/ws.util.ts b/test/ws.util.ts index dd800d5..a8c1534 100644 --- a/test/ws.util.ts +++ b/test/ws.util.ts @@ -1,31 +1,30 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable no-unused-vars */ /* eslint-disable @typescript-eslint/no-empty-function */ -import { WebsocketClient, WsClientEvent } from '../src'; +import { DefaultLogger, WebsocketClient } from '../src'; -export function getSilentLogger(_logHint?: string) { +export function getSilentLogger(_logHint?: string): typeof DefaultLogger { return { - silly: () => {}, - debug: () => {}, - notice: () => {}, + trace: () => {}, info: () => {}, - warning: () => {}, error: () => {}, }; } -export const fullLogger = { - silly: (...params) => console.log('silly', ...params), - debug: (...params) => console.log('debug', ...params), - notice: (...params) => console.log('notice', ...params), +export const fullLogger: typeof DefaultLogger = { + trace: (...params) => console.log('trace', ...params), info: (...params) => console.info('info', ...params), - warning: (...params) => console.warn('warning', ...params), error: (...params) => console.error('error', ...params), }; -export const WS_OPEN_EVENT_PARTIAL = { - type: 'open', -}; +export type WsClientEvent = + | 'open' + | 'update' + | 'close' + | 'error' + | 'reconnect' + | 'reconnected' + | 'response'; /** Resolves a promise if an event is seen before a timeout (defaults to 4.5 seconds) */ export function waitForSocketEvent( From e4957b049975655dcecdd6939e71a34ade6ec981 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Fri, 24 Jan 2025 15:02:02 +0000 Subject: [PATCH 28/60] chore(): remove decommissioned REST and WS clients --- examples/deprecated/rest-contract-private.ts | 24 -- examples/deprecated/rest-contract-public.js | 21 - examples/deprecated/rest-contract-public.ts | 20 - examples/deprecated/rest-copy-private.ts | 25 -- examples/deprecated/rest-linear-public.ts | 23 - examples/deprecated/rest-raw-v3sign.ts | 33 -- examples/deprecated/rest-spot-public.ts | 18 - examples/deprecated/rest-spot-tpsl.ts | 42 -- .../rest-unified-margin-private-cursor.ts | 44 -- .../rest-unified-margin-public-cursor.ts | 36 -- .../deprecated/ws-private-copytrading-v3.ts | 61 --- examples/deprecated/ws-private.ts | 69 --- examples/deprecated/ws-public.ts | 172 -------- src/account-asset-client-v3.ts | 314 -------------- src/account-asset-client.ts | 153 ------- src/contract-client.ts | 364 ---------------- src/copy-trading-client.ts | 162 ------- src/index.ts | 11 - src/inverse-client.ts | 341 --------------- src/inverse-futures-client.ts | 407 ------------------ src/linear-client.ts | 391 ----------------- src/spot-client-v3.ts | 265 +----------- src/spot-client.ts | 183 -------- src/types/request/index.ts | 1 - src/types/request/spot.ts | 89 ---- src/types/shared.ts | 32 +- src/types/websockets/ws-general.ts | 16 +- src/unified-margin-client.ts | 401 ----------------- src/usdc-option-client.ts | 337 --------------- src/usdc-perpetual-client.ts | 320 -------------- src/util/websockets/websocket-util.ts | 315 +------------- src/websocket-client.ts | 149 ++----- 32 files changed, 56 insertions(+), 4783 deletions(-) delete mode 100644 examples/deprecated/rest-contract-private.ts delete mode 100644 examples/deprecated/rest-contract-public.js delete mode 100644 examples/deprecated/rest-contract-public.ts delete mode 100644 examples/deprecated/rest-copy-private.ts delete mode 100644 examples/deprecated/rest-linear-public.ts delete mode 100644 examples/deprecated/rest-raw-v3sign.ts delete mode 100644 examples/deprecated/rest-spot-public.ts delete mode 100644 examples/deprecated/rest-spot-tpsl.ts delete mode 100644 examples/deprecated/rest-unified-margin-private-cursor.ts delete mode 100644 examples/deprecated/rest-unified-margin-public-cursor.ts delete mode 100644 examples/deprecated/ws-private-copytrading-v3.ts delete mode 100644 examples/deprecated/ws-private.ts delete mode 100644 examples/deprecated/ws-public.ts delete mode 100644 src/account-asset-client-v3.ts delete mode 100644 src/account-asset-client.ts delete mode 100644 src/contract-client.ts delete mode 100644 src/copy-trading-client.ts delete mode 100644 src/inverse-client.ts delete mode 100644 src/inverse-futures-client.ts delete mode 100644 src/linear-client.ts delete mode 100644 src/spot-client.ts delete mode 100644 src/types/request/spot.ts delete mode 100644 src/unified-margin-client.ts delete mode 100644 src/usdc-option-client.ts delete mode 100644 src/usdc-perpetual-client.ts diff --git a/examples/deprecated/rest-contract-private.ts b/examples/deprecated/rest-contract-private.ts deleted file mode 100644 index 59e1484..0000000 --- a/examples/deprecated/rest-contract-private.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ContractClient } from '../../src/index'; - -// or -// import { ContractClient } from 'bybit-api'; - -const key = process.env.API_KEY_COM; -const secret = process.env.API_SECRET_COM; - -const client = new ContractClient({ - key, - secret, - strict_param_validation: true, -}); - -(async () => { - try { - const getPositions = await client.getPositions({ - settleCoin: 'USDT', - }); - console.log('getPositions:', getPositions); - } catch (e) { - console.error('request failed: ', e); - } -})(); diff --git a/examples/deprecated/rest-contract-public.js b/examples/deprecated/rest-contract-public.js deleted file mode 100644 index d973b47..0000000 --- a/examples/deprecated/rest-contract-public.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * This is the pure javascript version of the `rest-contract-public.ts` sample - */ - -// To use a local build (testing with the repo directly), make sure to `npm run build` first from the repo root -// const { ContractClient } = require('../dist'); - -// or, use the version installed with npm -const { ContractClient } = require('bybit-api'); - -(async () => { - const client = new ContractClient(); - - try { - const orderbookResult = await client.getOrderBook('BTCUSDT', 'linear'); - console.log('orderbook result: ', orderbookResult); - } catch (e) { - console.error('request failed: ', e); - } - -})(); diff --git a/examples/deprecated/rest-contract-public.ts b/examples/deprecated/rest-contract-public.ts deleted file mode 100644 index 1fecb0b..0000000 --- a/examples/deprecated/rest-contract-public.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * This is the TypeScript version of the `rest-contract-public.js` sample. - */ - -// For testing with the repo directly, import from the src folder -import { ContractClient } from '../../src'; - -// or, use the version installed with npm -// import { ContractClient } from 'bybit-api'; - -(async () => { - const client = new ContractClient(); - - try { - const orderbookResult = await client.getOrderBook('BTCUSDT', 'linear'); - console.log('orderbook result: ', orderbookResult); - } catch (e) { - console.error('request failed: ', e); - } -})(); diff --git a/examples/deprecated/rest-copy-private.ts b/examples/deprecated/rest-copy-private.ts deleted file mode 100644 index ebced30..0000000 --- a/examples/deprecated/rest-copy-private.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { CopyTradingClient } from '../../src/index'; - -// or -// import { CopyTradingClient } from 'bybit-api'; - -const key = process.env.API_KEY_COM; -const secret = process.env.API_SECRET_COM; - -const client = new CopyTradingClient({ - key, - secret, - strict_param_validation: true, -}); - -(async () => { - try { - const res = await client.closeOrder({ - symbol: 'BTCUSDT', - parentOrderId: '419190fe-016c-469a-810e-936bef2f1234', - }); - console.log('res:', res); - } catch (e) { - console.error('request failed: ', e); - } -})(); diff --git a/examples/deprecated/rest-linear-public.ts b/examples/deprecated/rest-linear-public.ts deleted file mode 100644 index d241ee6..0000000 --- a/examples/deprecated/rest-linear-public.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { LinearClient } from '../../src/index'; - -// or -// import { LinearClient } from 'bybit-api'; - -const client = new LinearClient(); - -(async () => { - try { - // console.log('getSymbols: ', await client.getSymbols()); - // console.log('getOrderBook: ', await client.getOrderBook(symbol)); - console.log( - 'getKline: ', - await client.getKline({ - symbol: 'ETHUSDT', - interval: 'D', - from: 1, - }), - ); - } catch (e) { - console.error('request failed: ', e); - } -})(); diff --git a/examples/deprecated/rest-raw-v3sign.ts b/examples/deprecated/rest-raw-v3sign.ts deleted file mode 100644 index 35308a3..0000000 --- a/examples/deprecated/rest-raw-v3sign.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ContractClient } from '../../src/index'; - -// or -// import { ContractClient } from 'bybit-api'; - -const key = process.env.API_KEY_COM; -const secret = process.env.API_SECRET_COM; - -const client = new ContractClient({ - key, - secret, - strict_param_validation: true, -}); - -(async () => { - try { - /** - * You can make raw HTTP requests without the per-endpoint abstraction. - * - * The REST ContractClient uses bybit's v3 signature mechanism, - * so it can be used for raw calls to any v3-supporting endpoints (incl the V5 APIs). - * e.g. if an endpoint is missing and you desperately need it (but please raise an issue or PR if you're missing an endpoint) - */ - const rawCall = await client.getPrivate('/v5/order/realtime', { - category: 'linear', - symbol: 'BTCUSDT', - }); - - console.log('rawCall:', rawCall); - } catch (e) { - console.error('request failed: ', e); - } -})(); diff --git a/examples/deprecated/rest-spot-public.ts b/examples/deprecated/rest-spot-public.ts deleted file mode 100644 index 0574365..0000000 --- a/examples/deprecated/rest-spot-public.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { SpotClientV3 } from '../../src/index'; - -// or -// import { SpotClientV3 } from 'bybit-api'; - -const client = new SpotClientV3(); - -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/deprecated/rest-spot-tpsl.ts b/examples/deprecated/rest-spot-tpsl.ts deleted file mode 100644 index d296d6b..0000000 --- a/examples/deprecated/rest-spot-tpsl.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { SpotClientV3 } from '../../src/index'; - -// or -// import { SpotClientV3 } from 'bybit-api'; - -const symbol = 'BTCUSDT'; -const key = process.env.API_KEY_COM; -const secret = process.env.API_SECRET_COM; - -const client = new SpotClientV3({ - key, - secret, - strict_param_validation: true, -}); - -(async () => { - try { - const orderId = undefined; - const ordersPerPage = undefined; - - const orders = await client.getOpenOrders(symbol); - console.log('orders 1:', orders); - - const normalOrders = await client.getOpenOrders( - symbol, - orderId, - ordersPerPage, - 0, - ); - console.log('normal orders:', normalOrders); - - const tpSlOrders = await client.getOpenOrders( - symbol, - orderId, - ordersPerPage, - 1, - ); - console.log('tpSlOrders:', tpSlOrders); - } catch (e) { - console.error('request failed: ', e); - } -})(); diff --git a/examples/deprecated/rest-unified-margin-private-cursor.ts b/examples/deprecated/rest-unified-margin-private-cursor.ts deleted file mode 100644 index 0bde7cb..0000000 --- a/examples/deprecated/rest-unified-margin-private-cursor.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { UnifiedMarginClient } from '../../src/index'; - -// or -// import { UnifiedMarginClient } from 'bybit-api'; - -const key = process.env.API_KEY_COM; -const secret = process.env.API_SECRET_COM; - -const client = new UnifiedMarginClient({ - key, - secret, - strict_param_validation: true, -}); - -(async () => { - try { - // page 1 - const historicOrders1 = await client.getHistoricOrders({ - category: 'linear', - limit: 1, - // cursor, - }); - console.log('page 1:', JSON.stringify(historicOrders1, null, 2)); - - // page 2 - const historicOrders2 = await client.getHistoricOrders({ - category: 'linear', - limit: 1, - cursor: historicOrders1.result.nextPageCursor, - }); - console.log('page 2:', JSON.stringify(historicOrders2, null, 2)); - - const historicOrdersBoth = await client.getHistoricOrders({ - category: 'linear', - limit: 2, - }); - console.log( - 'both to compare:', - JSON.stringify(historicOrdersBoth, null, 2), - ); - } catch (e) { - console.error('request failed: ', e); - } -})(); diff --git a/examples/deprecated/rest-unified-margin-public-cursor.ts b/examples/deprecated/rest-unified-margin-public-cursor.ts deleted file mode 100644 index 224ed1a..0000000 --- a/examples/deprecated/rest-unified-margin-public-cursor.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { UnifiedMarginClient } from '../../src/index'; - -// or -// import { UnifiedMarginClient } from 'bybit-api'; - -const client = new UnifiedMarginClient({ - strict_param_validation: true, -}); - -(async () => { - try { - // page 1 - const historicOrders1 = await client.getInstrumentInfo({ - category: 'linear', - limit: '2', - }); - console.log('page 1:', JSON.stringify(historicOrders1, null, 2)); - - // page 2 - const historicOrders2 = await client.getInstrumentInfo({ - category: 'linear', - limit: '2', - cursor: historicOrders1.result.nextPageCursor, - }); - console.log('page 2:', JSON.stringify(historicOrders2, null, 2)); - - // page 1 & 2 in one request (for comparison) - const historicOrdersBoth = await client.getInstrumentInfo({ - category: 'linear', - limit: '4', - }); - console.log('both pages', JSON.stringify(historicOrdersBoth, null, 2)); - } catch (e) { - console.error('request failed: ', e); - } -})(); diff --git a/examples/deprecated/ws-private-copytrading-v3.ts b/examples/deprecated/ws-private-copytrading-v3.ts deleted file mode 100644 index 17e445d..0000000 --- a/examples/deprecated/ws-private-copytrading-v3.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* eslint-disable no-unused-vars */ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable @typescript-eslint/no-empty-function */ -import { DefaultLogger, WS_KEY_MAP, WebsocketClient } from '../../src'; - -// or -// import { DefaultLogger, WS_KEY_MAP, WebsocketClient } from 'bybit-api'; - -const logger = { - ...DefaultLogger, - silly: (...params) => { - // console.log(params); - }, -}; - -const key = process.env.API_KEY; -const secret = process.env.API_SECRET; - -/** - * Copy trading api docs say that private topics should connect to: wss://stream.bybit.com/realtime_private - * - * Within this SDK, only the market `linear` uses this endpoint for private topics: - */ -const market = 'linear'; - -const wsClient = new WebsocketClient( - { - key: key, - secret: secret, - market: market, - }, - 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); -}); -wsClient.on('response', (data) => { - console.log('ws 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); -}); -wsClient.on('error', (data) => { - console.error('ws exception: ', data); -}); - -// copy trading topics from api docs: https://bybit-exchange.github.io/docs/copy-trade/ws-private/position -wsClient.subscribe([ - 'copyTradePosition', - 'copyTradeOrder', - 'copyTradeExecution', - 'copyTradeWallet', -]); diff --git a/examples/deprecated/ws-private.ts b/examples/deprecated/ws-private.ts deleted file mode 100644 index 2ee6faf..0000000 --- a/examples/deprecated/ws-private.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* eslint-disable no-unused-vars */ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable @typescript-eslint/no-empty-function */ -import { DefaultLogger, WS_KEY_MAP, WebsocketClient } from '../../src'; - -// or -// import { DefaultLogger, WS_KEY_MAP, WebsocketClient } from 'bybit-api'; - -const logger = { - ...DefaultLogger, - silly: () => {}, -}; - -const key = process.env.API_KEY; -const secret = process.env.API_SECRET; - -// USDT Perps: -// const market = 'linear'; -// Inverse Perp -// const market = 'inverse'; -// const market = 'spotv3'; -// Contract v3 -const market = 'contractUSDT'; -// const market = 'contractInverse'; - -const wsClient = new WebsocketClient( - { - key: key, - secret: secret, - market: market, - // testnet: true, - restOptions: { - // enable_time_sync: true, - }, - }, - 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); -}); -wsClient.on('response', (data) => { - console.log('ws 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); -}); -wsClient.on('error', (data) => { - console.error('ws exception: ', data); -}); - -// subscribe to private endpoints -// check the api docs in your api category to see the available topics -// wsClient.subscribe(['position', 'execution', 'order', 'wallet']); - -// Contract v3 -wsClient.subscribe([ - 'user.position.contractAccount', - 'user.execution.contractAccount', - 'user.order.contractAccount', - 'user.wallet.contractAccount', -]); diff --git a/examples/deprecated/ws-public.ts b/examples/deprecated/ws-public.ts deleted file mode 100644 index e1cdb0a..0000000 --- a/examples/deprecated/ws-public.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { DefaultLogger, WS_KEY_MAP, WebsocketClient } from '../../src'; - -// or -// import { DefaultLogger, WS_KEY_MAP, WebsocketClient } from 'bybit-api'; - -const logger = { - ...DefaultLogger, - silly: (...params) => console.log('silly', ...params), -}; - -const wsClient = new WebsocketClient( - { - // key: key, - // secret: secret, - // market: 'linear', - // market: 'inverse', - // market: 'spot', - // market: 'spotv3', - // market: 'usdcOption', - // market: 'usdcPerp', - // market: 'unifiedPerp', - // market: 'unifiedOption', - market: 'contractUSDT', - }, - logger, -); - -wsClient.on('update', (data) => { - console.log('raw message received ', JSON.stringify(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 === WS_KEY_MAP.spotPublic) { - // // Spot public, but not recommended - use spotv3 client instead - // // The old spot websockets dont automatically resubscribe if they disconnect - // // 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); -}); -// wsClient.on('error', (data) => { -// console.error('ws exception: ', data); -// }); - -// Inverse -// wsClient.subscribe('trade'); - -// Linear -// wsClient.subscribe('trade.BTCUSDT'); - -// Spot V3 -// wsClient.subscribe('trade.BTCUSDT'); -// Or an array of topics -// wsClient.subscribe([ -// 'orderbook.40.BTCUSDT', -// 'orderbook.40.BTCUSDC', -// 'orderbook.40.USDCUSDT', -// 'orderbook.40.BTCDAI', -// 'orderbook.40.DAIUSDT', -// 'orderbook.40.ETHUSDT', -// 'orderbook.40.ETHUSDC', -// 'orderbook.40.ETHDAI', -// 'orderbook.40.XRPUSDT', -// 'orderbook.40.XRPUSDC', -// 'orderbook.40.EOSUSDT', -// 'orderbook.40.EOSUSDC', -// 'orderbook.40.DOTUSDT', -// 'orderbook.40.DOTUSDC', -// 'orderbook.40.XLMUSDT', -// 'orderbook.40.XLMUSDC', -// 'orderbook.40.LTCUSDT', -// 'orderbook.40.LTCUSDC', -// 'orderbook.40.DOGEUSDT', -// 'orderbook.40.DOGEUSDC', -// 'orderbook.40.BITUSDT', -// 'orderbook.40.BITUSDC', -// 'orderbook.40.BITDAI', -// 'orderbook.40.CHZUSDT', -// 'orderbook.40.CHZUSDC', -// 'orderbook.40.MANAUSDT', -// 'orderbook.40.MANAUSDC', -// 'orderbook.40.LINKUSDT', -// 'orderbook.40.LINKUSDC', -// 'orderbook.40.ICPUSDT', -// 'orderbook.40.ICPUSDC', -// 'orderbook.40.ADAUSDT', -// 'orderbook.40.ADAUSDC', -// 'orderbook.40.SOLUSDC', -// 'orderbook.40.SOLUSDT', -// 'orderbook.40.MATICUSDC', -// 'orderbook.40.MATICUSDT', -// 'orderbook.40.SANDUSDC', -// 'orderbook.40.SANDUSDT', -// 'orderbook.40.LUNCUSDC', -// 'orderbook.40.LUNCUSDT', -// 'orderbook.40.SLGUSDC', -// 'orderbook.40.SLGUSDT', -// 'orderbook.40.AVAXUSDC', -// 'orderbook.40.AVAXUSDT', -// 'orderbook.40.OPUSDC', -// 'orderbook.40.OPUSDT', -// 'orderbook.40.OKSEUSDC', -// 'orderbook.40.OKSEUSDT', -// 'orderbook.40.APEXUSDC', -// 'orderbook.40.APEXUSDT', -// 'orderbook.40.TRXUSDC', -// 'orderbook.40.TRXUSDT', -// 'orderbook.40.GMTUSDC', -// 'orderbook.40.GMTUSDT', -// 'orderbook.40.SHIBUSDC', -// 'orderbook.40.SHIBUSDT', -// 'orderbook.40.LDOUSDC', -// 'orderbook.40.LDOUSDT', -// 'orderbook.40.APEUSDC', -// 'orderbook.40.APEUSDT', -// 'orderbook.40.FILUSDC', -// 'orderbook.40.FILUSDT', -// ]); - -// usdc options -// wsClient.subscribe([ -// `recenttrades.BTC`, -// `recenttrades.ETH`, -// `recenttrades.SOL`, -// ]); - -// usdc perps (note: the syntax is different for the unified perp market) -// (market: 'usdcPerp') -// wsClient.subscribe('trade.BTCUSDC'); -// wsClient.subscribe('instrument_info.100ms.BTCPERP'); - -// unified perps -// wsClient.subscribe('publicTrade.BTCUSDT'); -// wsClient.subscribe('publicTrade.BTCPERP'); - -// For spot v1 (the old, deprecated client), request public connection first then send required topics on 'open' -// Not necessary for spot v3 -// wsClient.connectPublic(); - -// To unsubscribe from topics (after a 5 second delay, in this example): -// setTimeout(() => { -// console.log('unsubscribing'); -// wsClient.unsubscribe('trade.BTCUSDT'); -// }, 5 * 1000); - -// Topics are tracked per websocket type -// Get a list of subscribed topics (e.g. for public v3 spot topics) (after a 5 second delay) -setTimeout(() => { - const publicSpotTopics = wsClient - .getWsStore() - .getTopics(WS_KEY_MAP.spotV3Public); - - console.log('public spot topics: ', publicSpotTopics); - - const privateSpotTopics = wsClient - .getWsStore() - .getTopics(WS_KEY_MAP.spotV3Private); - console.log('private spot topics: ', privateSpotTopics); -}, 5 * 1000); diff --git a/src/account-asset-client-v3.ts b/src/account-asset-client-v3.ts deleted file mode 100644 index 9b1adb1..0000000 --- a/src/account-asset-client-v3.ts +++ /dev/null @@ -1,314 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - APIKeyInfoV3, - APIResponseV3WithTime, - APIResponseWithTime, - AccountCoinBalanceResponseV3, - AccountCoinBalancesRequestV3, - AccountCoinBalancesResponseV3, - AssetInfoRequestV3, - AssetInfoResponseV3, - CoinInfoQueryResponseV3, - CreateSubAPIKeyRequestV3, - CreateSubAPIKeyResponseV3, - CreateSubMemberRequestV3, - CreateSubMemberResponseV3, - DepositAddressResponseV3, - DepositRecordQueryRequestV3, - DepositRecordQueryResponseV3, - InternalTransferRequestV3, - ModifyAPIKeyRequestV3, - QueryDepositAddressRequestV3, - QueryInternalTransferSResponseV3, - QueryInternalTransfersRequestV3, - QuerySubAccountDepositAddressRequestV3, - SingleAccountCoinBalanceRequestV3, - SubAccountTransferRequestV3, - SubAccountTransferResponseV3, - SubDepositRecordQueryRequestV3, - SubMemberResponseV3, - SupportedDepositListRequestV3, - SupportedDepositListResponseV3, - TransferCoinListRequestV3, - UniversalTransferCreateResponse, - UniversalTransferListRequestV3, - UniversalTransferListResponseV3, - UniversalTransferRequestV3, - WithdrawCreateRequestV3, - WithdrawRecordQueryRequestV3, - WithdrawRecordsQueryResponseV3, -} from './types'; -import { REST_CLIENT_TYPE_ENUM } from './util'; -import BaseRestClient from './util/BaseRestClient'; - -/** - * REST API client for Account Asset V3 APIs - * @deprecated WARNING - * These endpoints are being switched off gradually and are expected to be completely turned off by the end of 2024. - * They may stop working at any point before then. - * Please update your code as soon as possible to use the V5 APIs instead. - */ -export class AccountAssetClientV3 extends BaseRestClient { - getClientType() { - return REST_CLIENT_TYPE_ENUM.v3; - } - - async fetchServerTime(): Promise { - const res = await this.getServerTime(); - return Number(res.time); - } - - getServerTime(): Promise< - APIResponseV3WithTime<{ timeSecond: string; timeNano: string }> - > { - return this.get('/v3/public/time'); - } - - /** - * - * Transfer Data Endpoints - * - */ - - createInternalTransfer( - params: InternalTransferRequestV3, - ): Promise> { - return this.postPrivate( - '/asset/v3/private/transfer/inter-transfer', - params, - ); - } - - getInternalTransfers( - params: QueryInternalTransfersRequestV3, - ): Promise> { - return this.getPrivate( - '/asset/v3/private/transfer/inter-transfer/list/query', - params, - ); - } - - createSubAccountTransfer(params: { - transferId: string; - coin: string; - amount: string; - subMemberId: number; - type: 'IN' | 'OUT'; - }): Promise> { - return this.postPrivate( - '/asset/v3/private/transfer/sub-member-transfer', - params, - ); - } - - getSubAccountTransfers( - params?: SubAccountTransferRequestV3, - ): Promise> { - return this.getPrivate( - '/asset/v3/private/transfer/sub-member-transfer/list/query', - params, - ); - } - - getSubAccounts(): Promise< - APIResponseWithTime<{ - subMemberIds: string[]; - transferableSubMemberIds: string[]; - }> - > { - return this.getPrivate('/asset/v3/private/transfer/sub-member/list/query'); - } - - enableUniversalTransfer(params?: { - subMemberIds?: string; - }): Promise> { - return this.postPrivate( - '/asset/v3/private/transfer/transfer-sub-member-save', - params, - ); - } - - createUniversalTransfer( - params: UniversalTransferRequestV3, - ): Promise> { - return this.postPrivate( - '/asset/v3/private/transfer/universal-transfer', - params, - ); - } - - getUniversalTransfers( - params: UniversalTransferListRequestV3, - ): Promise> { - return this.getPrivate( - '/asset/v3/private/transfer/universal-transfer/list/query', - params, - ); - } - - getTransferableCoinList( - params: TransferCoinListRequestV3, - ): Promise> { - return this.getPrivate( - '/asset/v3/private/transfer/transfer-coin/list/query', - params, - ); - } - - getAccountCoinBalance( - params: SingleAccountCoinBalanceRequestV3, - ): Promise> { - return this.getPrivate( - '/asset/v3/private/transfer/account-coin/balance/query', - params, - ); - } - - getAccountCoinBalances( - params: AccountCoinBalancesRequestV3, - ): Promise> { - return this.getPrivate( - '/asset/v3/private/transfer/account-coins/balance/query', - params, - ); - } - - getAssetInfo( - params?: AssetInfoRequestV3, - ): Promise> { - return this.getPrivate( - '/asset/v3/private/transfer/asset-info/query', - params, - ); - } - - /** - * - * Wallet & Deposit Endpoints - * - */ - - /** Get Deposit Spec */ - getSupportedDepositList( - params?: SupportedDepositListRequestV3, - ): Promise> { - return this.get( - '/asset/v3/public/deposit/allowed-deposit-list/query', - params, - ); - } - - getDepositRecords( - params?: DepositRecordQueryRequestV3, - ): Promise> { - return this.getPrivate('/asset/v3/private/deposit/record/query', params); - } - - getSubDepositRecords( - params: SubDepositRecordQueryRequestV3, - ): Promise> { - return this.getPrivate( - '/asset/v3/private/deposit/sub-member-record/query', - params, - ); - } - - getWithdrawRecords( - params?: WithdrawRecordQueryRequestV3, - ): Promise> { - return this.getPrivate('/asset/v3/private/withdraw/record/query', params); - } - - getCoinInformation( - coin?: string, - ): Promise> { - return this.getPrivate('/asset/v3/private/coin-info/query', { coin }); - } - - submitWithdrawal( - params: WithdrawCreateRequestV3, - ): Promise> { - return this.postPrivate('/asset/v3/private/withdraw/create', params); - } - - cancelWithdrawal( - withdrawalId: number, - ): Promise> { - return this.postPrivate('/asset/v3/private/withdraw/create', { - withdrawalId, - }); - } - - getMasterAccountDepositAddress( - params?: QueryDepositAddressRequestV3, - ): Promise> { - return this.getPrivate('/asset/v3/private/deposit/address/query', params); - } - - getSubAccountDepositAddress( - params: QuerySubAccountDepositAddressRequestV3, - ): Promise> { - return this.getPrivate( - '/asset/v3/private/deposit/sub-member-address/query', - params, - ); - } - - createSubMember( - params: CreateSubMemberRequestV3, - ): Promise> { - return this.postPrivate('/user/v3/private/create-sub-member', params); - } - - createSubAPIKey( - params: CreateSubAPIKeyRequestV3, - ): Promise> { - return this.postPrivate('/user/v3/private/create-sub-api', params); - } - - /** - * Get Sub UID List - */ - getSubMembers(): Promise> { - return this.getPrivate('/user/v3/private/query-sub-members'); - } - - /** - * Froze Sub UID - */ - freezeSubMember( - subuid: number, - frozenStatus: 0 | 1, - ): Promise> { - return this.postPrivate('/user/v3/private/frozen-sub-member', { - subuid, - frozen: frozenStatus, - }); - } - - getAPIKeyInformation(): Promise> { - return this.getPrivate('/user/v3/private/query-api'); - } - - modifyMasterAPIKey( - params: ModifyAPIKeyRequestV3, - ): Promise> { - return this.postPrivate('/user/v3/private/update-api', params); - } - - modifySubAPIKey( - params: ModifyAPIKeyRequestV3, - ): Promise> { - return this.postPrivate('/user/v3/private/update-sub-api', params); - } - - /** WARNING: BE CAREFUL! The API key used to call this interface will be invalid immediately. */ - deleteMasterAPIKey(): Promise> { - return this.postPrivate('/user/v3/private/delete-api'); - } - - /** WARNING: BE CAREFUL! The API key used to call this interface will be invalid immediately. */ - deleteSubAPIKey(): Promise> { - return this.postPrivate('/user/v3/private/delete-sub-api'); - } -} diff --git a/src/account-asset-client.ts b/src/account-asset-client.ts deleted file mode 100644 index cd30af0..0000000 --- a/src/account-asset-client.ts +++ /dev/null @@ -1,153 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - APIResponseWithTime, - AccountAssetInformationRequest, - DepositRecordsRequest, - EnableUniversalTransferRequest, - InternalTransferRequest, - SubAccountTransferRequest, - SupportedDepositListRequest, - TransferQueryRequest, - UniversalTransferRequest, - WithdrawalRecordsRequest, - WithdrawalRequest, -} from './types'; -import { REST_CLIENT_TYPE_ENUM } from './util'; -import BaseRestClient from './util/BaseRestClient'; - -/** - * REST API client for Account Asset APIs - * - * @deprecated WARNING: V1/V2 private endpoints (Rest API & Websocket Stream) for mainnet - * will be switched off gradually from 30 Oct 2023 UTC, so they are not promised a stability. - * Please note that you are at your own risk of using old endpoints going forward, and please move to V5 ASAP. - */ -export class AccountAssetClient extends BaseRestClient { - getClientType() { - return REST_CLIENT_TYPE_ENUM.accountAsset; - } - - async fetchServerTime(): Promise { - const res = await this.getServerTime(); - return Number(res.time_now); - } - - /** - * - * Transfer Data Endpoints - * - */ - - createInternalTransfer( - params: InternalTransferRequest, - ): Promise> { - return this.postPrivate('/asset/v1/private/transfer', params); - } - - createSubAccountTransfer( - params: SubAccountTransferRequest, - ): Promise> { - return this.postPrivate('/asset/v1/private/sub-member/transfer', params); - } - - getInternalTransfers( - params?: TransferQueryRequest, - ): Promise> { - return this.getPrivate('/asset/v1/private/transfer/list', params); - } - - getSubAccountTransfers( - params?: TransferQueryRequest, - ): Promise> { - return this.getPrivate( - '/asset/v1/private/sub-member/transfer/list', - params, - ); - } - - getSubAccounts(): Promise> { - return this.getPrivate('/asset/v1/private/sub-member/member-ids'); - } - - enableUniversalTransfer( - params?: EnableUniversalTransferRequest, - ): Promise> { - return this.postPrivate('/asset/v1/private/transferable-subs/save', params); - } - - createUniversalTransfer( - params: UniversalTransferRequest, - ): Promise> { - return this.postPrivate('/asset/v1/private/universal/transfer', params); - } - - getUniversalTransfers( - params?: TransferQueryRequest, - ): Promise> { - return this.getPrivate('/asset/v1/private/universal/transfer/list', params); - } - - /** - * - * Wallet & Deposit Endpoints - * - */ - - getSupportedDepositList( - params?: SupportedDepositListRequest, - ): Promise> { - return this.get('/asset/v1/public/deposit/allowed-deposit-list', params); - } - - getDepositRecords( - params?: DepositRecordsRequest, - ): Promise> { - return this.getPrivate('/asset/v1/private/deposit/record/query', params); - } - - getWithdrawRecords( - params?: WithdrawalRecordsRequest, - ): Promise> { - return this.getPrivate('/asset/v1/private/withdraw/record/query', params); - } - - getCoinInformation(coin?: string): Promise> { - return this.getPrivate('/asset/v1/private/coin-info/query', { coin }); - } - - getAssetInformation( - params?: AccountAssetInformationRequest, - ): Promise> { - return this.getPrivate('/asset/v1/private/asset-info/query', params); - } - - submitWithdrawal( - params: WithdrawalRequest, - ): Promise> { - return this.postPrivate('/asset/v1/private/withdraw', params); - } - - cancelWithdrawal(withdrawalId: number): Promise> { - return this.postPrivate('/asset/v1/private/withdraw/cancel', { - id: withdrawalId, - }); - } - - getDepositAddress(coin: string): Promise> { - return this.getPrivate('/asset/v1/private/deposit/address', { coin }); - } - - /** - * - * API Data Endpoints - * - */ - - getServerTime(): Promise { - return this.get('/v2/public/time'); - } - - getApiAnnouncements(): Promise> { - return this.get('/v2/public/announcement'); - } -} diff --git a/src/contract-client.ts b/src/contract-client.ts deleted file mode 100644 index fb31fbd..0000000 --- a/src/contract-client.ts +++ /dev/null @@ -1,364 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - APIResponseV3, - APIResponseWithTime, - ContractActiveOrdersRequest, - ContractCancelOrderRequest, - ContractClosedPNLRequest, - ContractHistoricOrder, - ContractHistoricOrdersRequest, - ContractListResult, - ContractModifyOrderRequest, - ContractOrderRequest, - ContractPositionsRequest, - ContractSetAutoAddMarginRequest, - ContractSetMarginSwitchRequest, - ContractSetPositionModeRequest, - ContractSetTPSLRequest, - ContractSymbolTicker, - ContractUserExecutionHistoryRequest, - ContractWalletFundRecordRequest, - PaginatedResult, - UMCandlesRequest, - UMCategory, - UMFundingRateHistoryRequest, - UMInstrumentInfoRequest, - UMOpenInterestRequest, - UMOptionDeliveryPriceRequest, - UMPublicTradesRequest, -} from './types'; -import { REST_CLIENT_TYPE_ENUM } from './util'; -import BaseRestClient from './util/BaseRestClient'; - -/** - * REST API client for Derivatives V3 Contract APIs - * @deprecated WARNING - * These endpoints are being switched off gradually and are expected to be completely turned off by the end of 2024. - * They may stop working at any point before then. - * Please update your code as soon as possible to use the V5 APIs instead. - */ -export class ContractClient extends BaseRestClient { - getClientType() { - // Follows the same authentication mechanism as other v3 APIs (e.g. USDC) - return REST_CLIENT_TYPE_ENUM.v3; - } - - async fetchServerTime(): Promise { - const res = await this.getServerTime(); - return Number(res.time_now); - } - - /** - * - * Market Data Endpoints : these seem exactly the same as the unified margin market data endpoints - * - */ - - /** Query order book info. Each side has a depth of 25 orders. */ - getOrderBook( - symbol: string, - category?: string, - limit?: number, - ): Promise> { - return this.get('/derivatives/v3/public/order-book/L2', { - category, - symbol, - limit, - }); - } - - /** Get candles/klines */ - getCandles(params: UMCandlesRequest): Promise> { - return this.get('/derivatives/v3/public/kline', params); - } - - /** Get a symbol price/statistics ticker */ - getSymbolTicker( - category: UMCategory | '', - symbol?: string, - ): Promise>> { - return this.get('/derivatives/v3/public/tickers', { category, symbol }); - } - - /** Get trading rules per symbol/contract, incl price/amount/value/leverage filters */ - getInstrumentInfo( - params: UMInstrumentInfoRequest, - ): Promise> { - return this.get('/derivatives/v3/public/instruments-info', params); - } - - /** Query mark price kline (like getCandles() but for mark price). */ - getMarkPriceCandles(params: UMCandlesRequest): Promise> { - return this.get('/derivatives/v3/public/mark-price-kline', params); - } - - /** Query Index Price Kline */ - getIndexPriceCandles(params: UMCandlesRequest): Promise> { - return this.get('/derivatives/v3/public/index-price-kline', params); - } - - /** - * The funding rate is generated every 8 hours at 00:00 UTC, 08:00 UTC and 16:00 UTC. - * For example, if a request is sent at 12:00 UTC, the funding rate generated earlier that day at 08:00 UTC will be sent. - */ - getFundingRateHistory( - params: UMFundingRateHistoryRequest, - ): Promise> { - return this.get( - '/derivatives/v3/public/funding/history-funding-rate', - params, - ); - } - - /** Get Risk Limit */ - getRiskLimit( - category: UMCategory, - symbol: string, - ): Promise> { - return this.get('/derivatives/v3/public/risk-limit/list', { - category, - symbol, - }); - } - - /** Get option delivery price */ - getOptionDeliveryPrice( - params: UMOptionDeliveryPriceRequest, - ): Promise> { - return this.get('/derivatives/v3/public/delivery-price', params); - } - - /** Get public trading history */ - getTrades(params: UMPublicTradesRequest): Promise> { - return this.get('/derivatives/v3/public/recent-trade', params); - } - - /** - * Gets the total amount of unsettled contracts. - * In other words, the total number of contracts held in open positions. - */ - getOpenInterest(params: UMOpenInterestRequest): Promise> { - return this.get('/derivatives/v3/public/open-interest', params); - } - - /** - * - * Contract Account Endpoints - * - */ - - /** -> Order API */ - - /** Place an order */ - submitOrder(params: ContractOrderRequest): Promise> { - return this.postPrivate('/contract/v3/private/order/create', params); - } - - /** - * Query order history. - * - * As order creation/cancellation is asynchronous, the data returned from the interface may be delayed. - * To access order information in real-time, call getActiveOrders(). - */ - getHistoricOrders( - params: ContractHistoricOrdersRequest, - ): Promise>> { - return this.getPrivate('/contract/v3/private/order/list', params); - } - - /** Cancel order */ - cancelOrder(params: ContractCancelOrderRequest): Promise> { - return this.postPrivate('/contract/v3/private/order/cancel', params); - } - - /** Cancel all orders */ - cancelAllOrders(symbol: string): Promise> { - return this.postPrivate('/contract/v3/private/order/cancel-all', { - symbol, - }); - } - - /** - * Replace order - * - * Active order parameters (such as quantity, price) and stop order parameters - * cannot be modified in one request at the same time. - * - * Please request modification separately. - */ - modifyOrder(params: ContractModifyOrderRequest): Promise> { - return this.postPrivate('/contract/v3/private/order/replace', params); - } - - /** Query Open Order(s) (real-time) */ - getActiveOrders( - params: ContractActiveOrdersRequest, - ): Promise> { - return this.getPrivate( - '/contract/v3/private/order/unfilled-orders', - params, - ); - } - - /** -> Positions API */ - - /** - * Query my positions real-time. Accessing personal list of positions. - * Either symbol or settleCoin is required. - * Users can access their position holding information through this interface, such as the number of position holdings and wallet balance. - */ - getPositions(params?: ContractPositionsRequest): Promise> { - return this.getPrivate('/contract/v3/private/position/list', params); - } - - /** Set auto add margin, or Auto-Margin Replenishment. */ - setAutoAddMargin( - params: ContractSetAutoAddMarginRequest, - ): Promise> { - return this.postPrivate( - '/contract/v3/private/position/set-auto-add-margin', - params, - ); - } - - /** Switch cross margin mode/isolated margin mode */ - setMarginSwitch( - params: ContractSetMarginSwitchRequest, - ): Promise> { - return this.postPrivate( - '/contract/v3/private/position/switch-isolated', - params, - ); - } - - /** Supports switching between One-Way Mode and Hedge Mode at the coin level. */ - setPositionMode( - params: ContractSetPositionModeRequest, - ): Promise> { - return this.postPrivate( - '/contract/v3/private/position/switch-mode', - params, - ); - } - - /** - * Switch mode between Full or Partial - */ - setTPSLMode( - symbol: string, - tpSlMode: 'Full' | 'Partial', - ): Promise> { - return this.postPrivate('/contract/v3/private/position/switch-tpsl-mode', { - symbol, - tpSlMode, - }); - } - - /** Leverage setting. */ - setLeverage( - symbol: string, - buyLeverage: string, - sellLeverage: string, - ): Promise> { - return this.postPrivate('/contract/v3/private/position/set-leverage', { - symbol, - buyLeverage, - sellLeverage, - }); - } - - /** - * Set take profit, stop loss, and trailing stop for your open position. - * If using partial mode, TP/SL/TS orders will not close your entire position. - */ - setTPSL(params: ContractSetTPSLRequest): Promise> { - return this.postPrivate( - '/contract/v3/private/position/trading-stop', - params, - ); - } - - /** Set risk limit */ - setRiskLimit( - symbol: string, - riskId: number, - /** 0-one-way, 1-buy side, 2-sell side */ - positionIdx: 0 | 1 | 2, - ): Promise> { - return this.postPrivate('/contract/v3/private/position/set-risk-limit', { - symbol, - riskId, - positionIdx, - }); - } - - /** - * Get user's trading records. - * The results are ordered in descending order (the first item is the latest). Returns records up to 2 years old. - */ - getUserExecutionHistory( - params: ContractUserExecutionHistoryRequest, - ): Promise> { - return this.getPrivate('/contract/v3/private/execution/list', params); - } - - /** - * Get user's closed profit and loss records. - * The results are ordered in descending order (the first item is the latest). - */ - getClosedProfitAndLoss( - params: ContractClosedPNLRequest, - ): Promise> { - return this.getPrivate('/contract/v3/private/position/closed-pnl', params); - } - - /** Get the information of open interest limit. */ - getOpenInterestLimitInfo(symbol: string): Promise> { - return this.getPrivate('/contract/v3/private/position/limit-info', { - symbol, - }); - } - - /** -> Account API */ - - /** Query wallet balance */ - getBalances(coin?: string): Promise> { - return this.getPrivate('/contract/v3/private/account/wallet/balance', { - coin, - }); - } - - /** Get user trading fee rate */ - getTradingFeeRate(symbol?: string): Promise> { - return this.getPrivate('/contract/v3/private/account/fee-rate', { - symbol, - }); - } - - /** - * Get wallet fund records. - * This endpoint also shows exchanges from the Asset Exchange, - * where the types for the exchange are ExchangeOrderWithdraw and ExchangeOrderDeposit. - * - * This endpoint returns incomplete information for transfers involving the derivatives wallet. - * Use the account asset API for creating and querying internal transfers. - */ - getWalletFundRecords( - params?: ContractWalletFundRecordRequest, - ): Promise> { - return this.getPrivate( - '/contract/v3/private/account/wallet/fund-records', - params, - ); - } - - /** - * - * API Data Endpoints - * - */ - - getServerTime(): Promise { - return this.get('/v2/public/time'); - } -} diff --git a/src/copy-trading-client.ts b/src/copy-trading-client.ts deleted file mode 100644 index 1fa226f..0000000 --- a/src/copy-trading-client.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - APIResponseV3, - APIResponseWithTime, - CopyTradingCancelOrderRequest, - CopyTradingCloseOrderRequest, - CopyTradingOrderListRequest, - CopyTradingOrderRequest, - CopyTradingTradingStopRequest, - CopyTradingTransferRequest, -} from './types'; -import { REST_CLIENT_TYPE_ENUM } from './util'; -import BaseRestClient from './util/BaseRestClient'; - -/** - * REST API client for USDC Perpetual APIs - * @deprecated WARNING - * These endpoints are being switched off gradually and are expected to be completely turned off by the end of 2024. - * They may stop working at any point before then. - * Please update your code as soon as possible to use the V5 APIs instead. - */ -export class CopyTradingClient extends BaseRestClient { - getClientType() { - // Follows the same authentication mechanism as USDC APIs - return REST_CLIENT_TYPE_ENUM.v3; - } - - async fetchServerTime(): Promise { - const res = await this.getServerTime(); - return Number(res.time_now); - } - - /** - * - * Market Data Endpoints - * - */ - - getSymbols(): Promise> { - return this.get('/contract/v3/public/copytrading/symbol/list'); - } - - /** - * - * Account Data Endpoints - * - */ - - /** -> Order API */ - - /** Create order */ - submitOrder(params: CopyTradingOrderRequest): Promise> { - return this.postPrivate( - '/contract/v3/private/copytrading/order/create', - params, - ); - } - - /** Set Trading Stop */ - setTradingStop( - params: CopyTradingTradingStopRequest, - ): Promise> { - return this.postPrivate( - '/contract/v3/private/copytrading/order/trading-stop', - params, - ); - } - - /** Query Order List */ - getActiveOrders( - params?: CopyTradingOrderListRequest, - ): Promise> { - return this.getPrivate( - '/contract/v3/private/copytrading/order/list', - params, - ); - } - - /** Cancel order */ - cancelOrder( - params: CopyTradingCancelOrderRequest, - ): Promise> { - return this.postPrivate( - '/contract/v3/private/copytrading/order/cancel', - params, - ); - } - - /** Close Order. - * This endpoint's rate_limit will decrease by 10 per request; - * ie, one request to this endpoint consumes 10 from the limit allowed per minute. - */ - closeOrder( - params: CopyTradingCloseOrderRequest, - ): Promise> { - return this.postPrivate( - '/contract/v3/private/copytrading/order/close', - params, - ); - } - - /** -> Positions API */ - - /** Position List */ - getPositions(symbol?: string): Promise> { - return this.getPrivate('/contract/v3/private/copytrading/position/list', { - symbol, - }); - } - - /** Close Position */ - closePosition( - symbol: string, - positionIdx: string, - ): Promise> { - return this.postPrivate('/contract/v3/private/copytrading/position/close', { - symbol, - positionIdx, - }); - } - - /** Only integers can be set to set the leverage */ - setLeverage( - symbol: string, - buyLeverage: string, - sellLeverage: string, - ): Promise> { - return this.postPrivate( - '/contract/v3/private/copytrading/position/set-leverage', - { symbol, buyLeverage, sellLeverage }, - ); - } - - /** - * - * Wallet Data Endpoints - * - */ - - /** Get Wallet Balance */ - getBalances(): Promise> { - return this.getPrivate('/contract/v3/private/copytrading/wallet/balance'); - } - - /** Transfer */ - transfer(params: CopyTradingTransferRequest): Promise> { - return this.postPrivate( - '/contract/v3/private/copytrading/wallet/transfer', - params, - ); - } - - /** - * - * API Data Endpoints - * - */ - - getServerTime(): Promise { - return this.get('/v2/public/time'); - } -} diff --git a/src/index.ts b/src/index.ts index c93f1d3..2a85c6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,5 @@ -export * from './account-asset-client'; -export * from './account-asset-client-v3'; -export * from './copy-trading-client'; -export * from './inverse-client'; -export * from './inverse-futures-client'; -export * from './linear-client'; export * from './rest-client-v5'; -export * from './spot-client'; export * from './spot-client-v3'; -export * from './usdc-option-client'; -export * from './usdc-perpetual-client'; -export * from './unified-margin-client'; -export * from './contract-client'; export * from './websocket-client'; export * from './util/logger'; export * from './util'; diff --git a/src/inverse-client.ts b/src/inverse-client.ts deleted file mode 100644 index b860e17..0000000 --- a/src/inverse-client.ts +++ /dev/null @@ -1,341 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { REST_CLIENT_TYPE_ENUM } from './util'; -import { - APIResponseWithTime, - AssetExchangeRecordsReq, - CoinParam, - InverseActiveConditionalOrderRequest, - InverseActiveOrdersRequest, - InverseCancelConditionalOrderRequest, - InverseCancelOrderRequest, - InverseChangePositionMarginRequest, - InverseConditionalOrderRequest, - InverseGetClosedPnlRequest, - InverseGetOrderRequest, - InverseGetTradeRecordsRequest, - InverseOrderRequest, - InverseReplaceConditionalOrderRequest, - InverseReplaceOrderRequest, - InverseSetLeverageRequest, - InverseSetMarginTypeRequest, - InverseSetSlTpPositionModeRequest, - InverseSetTradingStopRequest, - SymbolInfo, - SymbolIntervalFromLimitParam, - SymbolLimitParam, - SymbolParam, - SymbolPeriodLimitParam, - WalletFundRecordsReq, - WithdrawRecordsReq, -} from './types'; -import BaseRestClient from './util/BaseRestClient'; - -/** - * REST API client for Inverse Perpetual Futures APIs (v2) - * - * @deprecated WARNING: V1/V2 private endpoints (Rest API & Websocket Stream) for mainnet - * will be switched off gradually from 30 Oct 2023 UTC, so they are not promised a stability. - * Please note that you are at your own risk of using old endpoints going forward, and please move to V5 ASAP. - */ -export class InverseClient extends BaseRestClient { - getClientType() { - return REST_CLIENT_TYPE_ENUM.inverse; - } - - async fetchServerTime(): Promise { - const res = await this.getServerTime(); - return Number(res.time_now); - } - - /** - * - * Market Data Endpoints - * - */ - - getOrderBook(params: SymbolParam): Promise> { - return this.get('v2/public/orderBook/L2', params); - } - - getKline( - params: SymbolIntervalFromLimitParam, - ): Promise> { - return this.get('v2/public/kline/list', params); - } - - /** - * Get latest information for symbol - */ - getTickers( - params?: Partial, - ): Promise> { - return this.get('v2/public/tickers', params); - } - - getTrades(params: SymbolLimitParam): Promise> { - return this.get('v2/public/trading-records', params); - } - - getSymbols(): Promise> { - return this.get('v2/public/symbols'); - } - - getMarkPriceKline( - params: SymbolIntervalFromLimitParam, - ): Promise> { - return this.get('v2/public/mark-price-kline', params); - } - - getIndexPriceKline( - params: SymbolIntervalFromLimitParam, - ): Promise> { - return this.get('v2/public/index-price-kline', params); - } - - getPremiumIndexKline( - params: SymbolIntervalFromLimitParam, - ): Promise> { - return this.get('v2/public/premium-index-kline', params); - } - - /** - * - * Market Data : Advanced - * - */ - - getOpenInterest( - params: SymbolPeriodLimitParam, - ): Promise> { - return this.get('v2/public/open-interest', params); - } - - getLatestBigDeal( - params: SymbolLimitParam, - ): Promise> { - return this.get('v2/public/big-deal', params); - } - - getLongShortRatio( - params: SymbolPeriodLimitParam, - ): Promise> { - return this.get('v2/public/account-ratio', params); - } - - /** - * - * Account Data Endpoints - * - */ - - getApiKeyInfo(): Promise> { - return this.getPrivate('v2/private/account/api-key'); - } - - /** - * - * Wallet Data Endpoints - * - */ - - getWalletBalance( - params?: Partial, - ): Promise> { - return this.getPrivate('v2/private/wallet/balance', params); - } - - getWalletFundRecords( - params?: WalletFundRecordsReq, - ): Promise> { - return this.getPrivate('v2/private/wallet/fund/records', params); - } - - getWithdrawRecords( - params?: WithdrawRecordsReq, - ): Promise> { - return this.getPrivate('v2/private/wallet/withdraw/list', params); - } - - getAssetExchangeRecords( - params?: AssetExchangeRecordsReq, - ): Promise> { - return this.getPrivate('v2/private/exchange-order/list', params); - } - - /** - * - * API Data Endpoints - * - */ - - getServerTime(): Promise> { - return this.get('v2/public/time'); - } - - getApiAnnouncements(): Promise> { - return this.get('v2/public/announcement'); - } - - /** - * - * Account Data Endpoints - * - */ - - /** - * Active orders - */ - - placeActiveOrder( - orderRequest: InverseOrderRequest, - ): Promise> { - return this.postPrivate('v2/private/order/create', orderRequest); - } - - getActiveOrderList( - params: InverseActiveOrdersRequest, - ): Promise> { - return this.getPrivate('v2/private/order/list', params); - } - - cancelActiveOrder( - params: InverseCancelOrderRequest, - ): Promise> { - return this.postPrivate('v2/private/order/cancel', params); - } - - cancelAllActiveOrders( - params: SymbolParam, - ): Promise> { - return this.postPrivate('v2/private/order/cancelAll', params); - } - - replaceActiveOrder( - params: InverseReplaceOrderRequest, - ): Promise> { - return this.postPrivate('v2/private/order/replace', params); - } - - queryActiveOrder( - params: InverseGetOrderRequest, - ): Promise> { - return this.getPrivate('v2/private/order', params); - } - - /** - * Conditional orders - */ - - placeConditionalOrder( - params: InverseConditionalOrderRequest, - ): Promise> { - return this.postPrivate('v2/private/stop-order/create', params); - } - - /** get conditional order list. This may see delays, use queryConditionalOrder() for real-time queries */ - getConditionalOrder( - params: InverseActiveConditionalOrderRequest, - ): Promise> { - return this.getPrivate('v2/private/stop-order/list', params); - } - - cancelConditionalOrder( - params: InverseCancelConditionalOrderRequest, - ): Promise> { - return this.postPrivate('v2/private/stop-order/cancel', params); - } - - cancelAllConditionalOrders( - params: SymbolParam, - ): Promise> { - return this.postPrivate('v2/private/stop-order/cancelAll', params); - } - - replaceConditionalOrder( - params: InverseReplaceConditionalOrderRequest, - ): Promise> { - return this.postPrivate('v2/private/stop-order/replace', params); - } - - queryConditionalOrder( - params: InverseGetOrderRequest, - ): Promise> { - return this.getPrivate('v2/private/stop-order', params); - } - - /** - * Position - */ - - getPosition( - params?: Partial, - ): Promise> { - return this.getPrivate('v2/private/position/list', params); - } - - changePositionMargin( - params: InverseChangePositionMarginRequest, - ): Promise> { - return this.postPrivate('position/change-position-margin', params); - } - - setTradingStop( - params: InverseSetTradingStopRequest, - ): Promise> { - return this.postPrivate('v2/private/position/trading-stop', params); - } - - setUserLeverage( - params: InverseSetLeverageRequest, - ): Promise> { - return this.postPrivate('v2/private/position/leverage/save', params); - } - - getTradeRecords( - params: InverseGetTradeRecordsRequest, - ): Promise> { - return this.getPrivate('v2/private/execution/list', params); - } - - getClosedPnl( - params: InverseGetClosedPnlRequest, - ): Promise> { - return this.getPrivate('v2/private/trade/closed-pnl/list', params); - } - - setSlTpPositionMode( - params: InverseSetSlTpPositionModeRequest, - ): Promise> { - return this.postPrivate('v2/private/tpsl/switch-mode', params); - } - - setMarginType( - params: InverseSetMarginTypeRequest, - ): Promise> { - return this.postPrivate('v2/private/position/switch-isolated', params); - } - - /** - * Funding - */ - - getLastFundingRate(params: SymbolParam): Promise> { - return this.get('v2/public/funding/prev-funding-rate', params); - } - - getMyLastFundingFee(params: SymbolParam): Promise> { - return this.getPrivate('v2/private/funding/prev-funding', params); - } - - getPredictedFunding(params: SymbolParam): Promise> { - return this.getPrivate('v2/private/funding/predicted-funding', params); - } - - /** - * LCP Info - */ - - getLcpInfo(params: SymbolParam): Promise> { - return this.getPrivate('v2/private/account/lcp', params); - } -} diff --git a/src/inverse-futures-client.ts b/src/inverse-futures-client.ts deleted file mode 100644 index eb8646b..0000000 --- a/src/inverse-futures-client.ts +++ /dev/null @@ -1,407 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { REST_CLIENT_TYPE_ENUM } from './util/requestUtils'; -import { - APIResponseWithTime, - AssetExchangeRecordsReq, - CoinParam, - SymbolInfo, - SymbolIntervalFromLimitParam, - SymbolLimitParam, - SymbolParam, - SymbolPeriodLimitParam, - WalletFundRecordsReq, - WithdrawRecordsReq, -} from './types/shared'; -import BaseRestClient from './util/BaseRestClient'; - -/** - * REST API client for Inverse Futures APIs (e.g. quarterly futures) (v2) - * - * @deprecated WARNING: V1/V2 private endpoints (Rest API & Websocket Stream) for mainnet - * will be switched off gradually from 30 Oct 2023 UTC, so they are not promised a stability. - * Please note that you are at your own risk of using old endpoints going forward, and please move to V5 ASAP. - */ -export class InverseFuturesClient extends BaseRestClient { - getClientType() { - return REST_CLIENT_TYPE_ENUM.inverseFutures; - } - - async fetchServerTime(): Promise { - const res = await this.getServerTime(); - return Number(res.time_now); - } - - /** - * - * Market Data Endpoints - * - */ - - getOrderBook(params: SymbolParam): Promise> { - return this.get('v2/public/orderBook/L2', params); - } - - getKline( - params: SymbolIntervalFromLimitParam, - ): Promise> { - return this.get('v2/public/kline/list', params); - } - - /** - * Get latest information for symbol - */ - getTickers( - params?: Partial, - ): Promise> { - return this.get('v2/public/tickers', params); - } - - /** - * Public trading records - */ - getTrades(params: SymbolLimitParam): Promise> { - return this.get('v2/public/trading-records', params); - } - - getSymbols(): Promise> { - return this.get('v2/public/symbols'); - } - - getMarkPriceKline( - params: SymbolIntervalFromLimitParam, - ): Promise> { - return this.get('v2/public/mark-price-kline', params); - } - - getIndexPriceKline( - params: SymbolIntervalFromLimitParam, - ): Promise> { - return this.get('v2/public/index-price-kline', params); - } - - getPremiumIndexKline( - params: SymbolIntervalFromLimitParam, - ): Promise> { - return this.get('v2/public/premium-index-kline', params); - } - - /** - * - * Market Data : Advanced - * - */ - - getOpenInterest( - params: SymbolPeriodLimitParam, - ): Promise> { - return this.get('v2/public/open-interest', params); - } - - getLatestBigDeal( - params: SymbolLimitParam, - ): Promise> { - return this.get('v2/public/big-deal', params); - } - - getLongShortRatio( - params: SymbolPeriodLimitParam, - ): Promise> { - return this.get('v2/public/account-ratio', params); - } - - /** - * - * Account Data Endpoints - * - */ - - getApiKeyInfo(): Promise> { - return this.getPrivate('v2/private/account/api-key'); - } - - /** - * - * Wallet Data Endpoints - * - */ - - getWalletBalance( - params?: Partial, - ): Promise> { - return this.getPrivate('v2/private/wallet/balance', params); - } - - getWalletFundRecords( - params?: WalletFundRecordsReq, - ): Promise> { - return this.getPrivate('v2/private/wallet/fund/records', params); - } - - getWithdrawRecords( - params?: WithdrawRecordsReq, - ): Promise> { - return this.getPrivate('v2/private/wallet/withdraw/list', params); - } - - getAssetExchangeRecords( - params?: AssetExchangeRecordsReq, - ): Promise> { - return this.getPrivate('v2/private/exchange-order/list', params); - } - - /** - * - * API Data Endpoints - * - */ - - getServerTime(): Promise> { - return this.get('v2/public/time'); - } - - getApiAnnouncements(): Promise> { - return this.get('v2/public/announcement'); - } - - /** - * - * Account Data Endpoints - * - */ - - /** - * Active orders - */ - - placeActiveOrder(orderRequest: { - side: string; - symbol: string; - order_type: string; - qty: number; - price?: number; - time_in_force: string; - take_profit?: number; - stop_loss?: number; - reduce_only?: boolean; - close_on_trigger?: boolean; - order_link_id?: string; - }): Promise> { - return this.postPrivate('futures/private/order/create', orderRequest); - } - - getActiveOrderList(params: { - symbol: string; - order_status?: string; - direction?: string; - limit?: number; - cursor?: string; - }): Promise> { - return this.getPrivate('futures/private/order/list', params); - } - - cancelActiveOrder(params: { - symbol: string; - order_id?: string; - order_link_id?: string; - }): Promise> { - return this.postPrivate('futures/private/order/cancel', params); - } - - cancelAllActiveOrders( - params: SymbolParam, - ): Promise> { - return this.postPrivate('futures/private/order/cancelAll', params); - } - - replaceActiveOrder(params: { - order_id?: string; - order_link_id?: string; - symbol: string; - p_r_qty?: string; - p_r_price?: string; - }): Promise> { - return this.postPrivate('futures/private/order/replace', params); - } - - queryActiveOrder(params: { - order_id?: string; - order_link_id?: string; - symbol: string; - }): Promise> { - return this.getPrivate('futures/private/order', params); - } - - /** - * Conditional orders - */ - - placeConditionalOrder(params: { - side: string; - symbol: string; - order_type: string; - qty: string; - price?: string; - base_price: string; - stop_px: string; - time_in_force: string; - trigger_by?: string; - close_on_trigger?: boolean; - order_link_id?: string; - }): Promise> { - return this.postPrivate('futures/private/stop-order/create', params); - } - - getConditionalOrder(params: { - symbol: string; - stop_order_status?: string; - direction?: string; - limit?: number; - cursor?: string; - }): Promise> { - return this.getPrivate('futures/private/stop-order/list', params); - } - - cancelConditionalOrder(params: { - symbol: string; - stop_order_id?: string; - order_link_id?: string; - }): Promise> { - return this.postPrivate('futures/private/stop-order/cancel', params); - } - - cancelAllConditionalOrders( - params: SymbolParam, - ): Promise> { - return this.postPrivate('futures/private/stop-order/cancelAll', params); - } - - replaceConditionalOrder(params: { - stop_order_id?: string; - order_link_id?: string; - symbol: string; - p_r_qty?: number; - p_r_price?: string; - p_r_trigger_price?: string; - }): Promise> { - return this.postPrivate('futures/private/stop-order/replace', params); - } - - queryConditionalOrder(params: { - symbol: string; - stop_order_id?: string; - order_link_id?: string; - }): Promise> { - return this.getPrivate('futures/private/stop-order', params); - } - - /** - * Position - */ - - /** - * Get position list - */ - getPosition( - params?: Partial, - ): Promise> { - return this.getPrivate('futures/private/position/list', params); - } - - changePositionMargin(params: { - symbol: string; - margin: string; - }): Promise> { - return this.postPrivate( - 'futures/private/position/change-position-margin', - params, - ); - } - - setTradingStop(params: { - symbol: string; - take_profit?: number; - stop_loss?: number; - trailing_stop?: number; - tp_trigger_by?: string; - sl_trigger_by?: string; - new_trailing_active?: number; - }): Promise> { - return this.postPrivate('futures/private/position/trading-stop', params); - } - - setUserLeverage(params: { - symbol: string; - buy_leverage: number; - sell_leverage: number; - }): Promise> { - return this.postPrivate('futures/private/position/leverage/save', params); - } - - /** - * Position mode switch - */ - setPositionMode(params: { - symbol: string; - mode: number; - }): Promise> { - return this.postPrivate('futures/private/position/switch-mode', params); - } - - /** - * Cross/Isolated margin switch. Must set leverage value when switching. - */ - setMarginType(params: { - symbol: string; - is_isolated: boolean; - buy_leverage: number; - sell_leverage: number; - }): Promise> { - return this.postPrivate('futures/private/position/switch-isolated', params); - } - - getTradeRecords(params: { - order_id?: string; - symbol: string; - start_time?: number; - page?: number; - limit?: number; - order?: string; - }): Promise> { - return this.getPrivate('futures/private/execution/list', params); - } - - getClosedPnl(params: { - symbol: string; - start_time?: number; - end_time?: number; - exec_type?: string; - page?: number; - limit?: number; - }): Promise> { - return this.getPrivate('futures/private/trade/closed-pnl/list', params); - } - - /** - * Funding - */ - - getLastFundingRate(params: SymbolParam): Promise> { - return this.get('v2/public/funding/prev-funding-rate', params); - } - - getMyLastFundingFee(params: SymbolParam): Promise> { - return this.getPrivate('v2/private/funding/prev-funding', params); - } - - getPredictedFunding(params: SymbolParam): Promise> { - return this.getPrivate('v2/private/funding/predicted-funding', params); - } - - /** - * LCP Info - */ - - getLcpInfo(params: SymbolParam): Promise> { - return this.getPrivate('v2/private/account/lcp', params); - } -} diff --git a/src/linear-client.ts b/src/linear-client.ts deleted file mode 100644 index 2e6a637..0000000 --- a/src/linear-client.ts +++ /dev/null @@ -1,391 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { REST_CLIENT_TYPE_ENUM } from './util/requestUtils'; -import { - APIResponse, - APIResponseWithTime, - AssetExchangeRecordsReq, - CoinParam, - LinearCancelConditionalOrderRequest, - LinearCancelOrderRequest, - LinearConditionalOrderRequest, - LinearGetClosedPnlRequest, - LinearGetConditionalOrderRequest, - LinearGetHistoryTradeRecordsRequest, - LinearGetOrderRequest, - LinearGetOrdersRequest, - LinearGetTradeRecordsRequest, - LinearOrder, - LinearQueryConditionalOrderRequest, - LinearReplaceConditionalOrderRequest, - LinearReplaceOrderRequest, - LinearSetAddReduceMarginRequest, - LinearSetAutoAddMarginRequest, - LinearSetMarginSwitchRequest, - LinearSetPositionModeRequest, - LinearSetPositionTpSlModeRequest, - LinearSetRiskLimitRequest, - LinearSetTradingStopRequest, - LinearSetUserLeverageRequest, - NewLinearOrder, - PerpPosition, - PerpPositionRoot, - SymbolInfo, - SymbolIntervalFromLimitParam, - SymbolLimitParam, - SymbolParam, - SymbolPeriodLimitParam, - WalletBalances, - WalletFundRecordsReq, - WithdrawRecordsReq, -} from './types'; -import BaseRestClient from './util/BaseRestClient'; - -/** - * REST API client for linear/USD perpetual futures APIs (v2) - * - * @deprecated WARNING: V1/V2 private endpoints (Rest API & Websocket Stream) for mainnet - * will be switched off gradually from 30 Oct 2023 UTC, so they are not promised a stability. - * Please note that you are at your own risk of using old endpoints going forward, and please move to V5 ASAP. - */ -export class LinearClient extends BaseRestClient { - getClientType() { - return REST_CLIENT_TYPE_ENUM.linear; - } - - async fetchServerTime(): Promise { - const timeRes = await this.getServerTime(); - return Number(timeRes.time_now); - } - - /** - * - * Market Data Endpoints - * - */ - - getOrderBook(params: SymbolParam): Promise> { - return this.get('v2/public/orderBook/L2', params); - } - - getKline( - params: SymbolIntervalFromLimitParam, - ): Promise> { - return this.get('public/linear/kline', params); - } - - /** - * Get latest information for symbol - */ - getTickers( - params?: Partial, - ): Promise> { - return this.get('v2/public/tickers', params); - } - - getTrades(params: SymbolLimitParam): Promise> { - return this.get('public/linear/recent-trading-records', params); - } - - getSymbols(): Promise> { - return this.get('v2/public/symbols'); - } - - getLastFundingRate(params: SymbolParam): Promise> { - return this.get('public/linear/funding/prev-funding-rate', params); - } - - getMarkPriceKline( - params: SymbolIntervalFromLimitParam, - ): Promise> { - return this.get('public/linear/mark-price-kline', params); - } - - getIndexPriceKline( - params: SymbolIntervalFromLimitParam, - ): Promise> { - return this.get('public/linear/index-price-kline', params); - } - - getPremiumIndexKline( - params: SymbolIntervalFromLimitParam, - ): Promise> { - return this.get('public/linear/premium-index-kline', params); - } - - /** - * - * Market Data : Advanced - * - */ - - getOpenInterest( - params: SymbolPeriodLimitParam, - ): Promise> { - return this.get('v2/public/open-interest', params); - } - - getLatestBigDeal( - params: SymbolLimitParam, - ): Promise> { - return this.get('v2/public/big-deal', params); - } - - getLongShortRatio( - params: SymbolPeriodLimitParam, - ): Promise> { - return this.get('v2/public/account-ratio', params); - } - - /** - * - * Account Data Endpoints - * - */ - - getApiKeyInfo(): Promise> { - return this.getPrivate('v2/private/account/api-key'); - } - - /** - * - * Wallet Data Endpoints - * - */ - - getWalletBalance( - params?: Partial, - ): Promise> { - return this.getPrivate('v2/private/wallet/balance', params); - } - - getWalletFundRecords( - params?: WalletFundRecordsReq, - ): Promise> { - return this.getPrivate('v2/private/wallet/fund/records', params); - } - - getWithdrawRecords( - params?: WithdrawRecordsReq, - ): Promise> { - return this.getPrivate('v2/private/wallet/withdraw/list', params); - } - - getAssetExchangeRecords( - params?: AssetExchangeRecordsReq, - ): Promise> { - return this.getPrivate('v2/private/exchange-order/list', params); - } - - /** - * - * API Data Endpoints - * - */ - - getServerTime(): Promise> { - return this.get('v2/public/time'); - } - - getApiAnnouncements(): Promise> { - return this.get('v2/public/announcement'); - } - - /** - * - * Account Data Endpoints - * - */ - - placeActiveOrder( - params: NewLinearOrder, - ): Promise> { - return this.postPrivate('private/linear/order/create', params); - } - - getActiveOrderList( - params: LinearGetOrdersRequest, - ): Promise> { - return this.getPrivate('private/linear/order/list', params); - } - - cancelActiveOrder( - params: LinearCancelOrderRequest, - ): Promise> { - return this.postPrivate('private/linear/order/cancel', params); - } - - cancelAllActiveOrders( - params: SymbolParam, - ): Promise> { - return this.postPrivate('private/linear/order/cancel-all', params); - } - - replaceActiveOrder( - params: LinearReplaceOrderRequest, - ): Promise> { - return this.postPrivate('private/linear/order/replace', params); - } - - queryActiveOrder( - params: LinearGetOrderRequest, - ): Promise> { - return this.getPrivate('private/linear/order/search', params); - } - - /** - * Conditional orders - */ - - placeConditionalOrder( - params: LinearConditionalOrderRequest, - ): Promise> { - return this.postPrivate('private/linear/stop-order/create', params); - } - - getConditionalOrder( - params: LinearGetConditionalOrderRequest, - ): Promise> { - return this.getPrivate('private/linear/stop-order/list', params); - } - - cancelConditionalOrder( - params: LinearCancelConditionalOrderRequest, - ): Promise> { - return this.postPrivate('private/linear/stop-order/cancel', params); - } - - cancelAllConditionalOrders( - params: SymbolParam, - ): Promise> { - return this.postPrivate('private/linear/stop-order/cancel-all', params); - } - - replaceConditionalOrder( - params: LinearReplaceConditionalOrderRequest, - ): Promise> { - return this.postPrivate('private/linear/stop-order/replace', params); - } - - queryConditionalOrder( - params: LinearQueryConditionalOrderRequest, - ): Promise> { - return this.getPrivate('private/linear/stop-order/search', params); - } - - /** - * Position - */ - - getPosition(): Promise>; - - getPosition( - params: Partial, - ): Promise>; - - getPosition( - params?: Partial, - ): Promise> { - return this.getPrivate('private/linear/position/list', params); - } - - setAutoAddMargin( - params?: LinearSetAutoAddMarginRequest, - ): Promise> { - return this.postPrivate( - 'private/linear/position/set-auto-add-margin', - params, - ); - } - - setMarginSwitch( - params?: LinearSetMarginSwitchRequest, - ): Promise> { - return this.postPrivate('private/linear/position/switch-isolated', params); - } - - /** - * Switch between one-way vs hedge mode. Use `linearPositionModeEnum` for the mode parameter. - */ - setPositionMode( - params: LinearSetPositionModeRequest, - ): Promise> { - return this.postPrivate('private/linear/position/switch-mode', params); - } - - /** - * Switch TP/SL mode between full or partial. When set to Partial, TP/SL orders may have a quantity less than the position size. - * This is set with the setTradingStop() method. Use `positionTpSlModeEnum` for the tp_sl_mode parameter. - */ - setPositionTpSlMode( - params: LinearSetPositionTpSlModeRequest, - ): Promise> { - return this.postPrivate('private/linear/tpsl/switch-mode', params); - } - - setAddReduceMargin( - params?: LinearSetAddReduceMarginRequest, - ): Promise> { - return this.postPrivate('private/linear/position/add-margin', params); - } - - setUserLeverage( - params: LinearSetUserLeverageRequest, - ): Promise> { - return this.postPrivate('private/linear/position/set-leverage', params); - } - - setTradingStop( - params: LinearSetTradingStopRequest, - ): Promise> { - return this.postPrivate('private/linear/position/trading-stop', params); - } - - getTradeRecords( - params: LinearGetTradeRecordsRequest, - ): Promise> { - return this.getPrivate('private/linear/trade/execution/list', params); - } - - getHistoryTradeRecords( - params: LinearGetHistoryTradeRecordsRequest, - ): Promise> { - return this.getPrivate( - '/private/linear/trade/execution/history-list', - params, - ); - } - - getClosedPnl( - params: LinearGetClosedPnlRequest, - ): Promise> { - return this.getPrivate('private/linear/trade/closed-pnl/list', params); - } - - /** - * Risk Limit - */ - - getRiskLimitList(params: SymbolParam): Promise> { - return this.getPrivate('public/linear/risk-limit', params); - } - - setRiskLimit( - params: LinearSetRiskLimitRequest, - ): Promise> { - return this.postPrivate('private/linear/position/set-risk', params); - } - - /** - * Funding - */ - - getPredictedFundingFee( - params: SymbolParam, - ): Promise> { - return this.getPrivate('private/linear/funding/predicted-funding', params); - } - - getLastFundingFee(params: SymbolParam): Promise> { - return this.getPrivate('private/linear/funding/prev-funding', params); - } -} diff --git a/src/spot-client-v3.ts b/src/spot-client-v3.ts index 4bc1e8f..8c8d0f4 100644 --- a/src/spot-client-v3.ts +++ b/src/spot-client-v3.ts @@ -1,17 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { - APIResponseV3, - APIResponseWithTime, - KlineInterval, - NewSpotOrderV3, - SpotBalances, - SpotCancelOrderBatchRequest, - SpotCrossMarginBorrowingInfoRequest, - SpotCrossMarginRepaymentHistoryRequest, - SpotLeveragedTokenPRHistoryRequest, - SpotMyTradesRequest, - SpotOrderQueryById, -} from './types'; +import { APIResponseV3, numberInString } from './types'; import { REST_CLIENT_TYPE_ENUM } from './util'; import BaseRestClient from './util/BaseRestClient'; @@ -39,17 +27,11 @@ export class SpotClientV3 extends BaseRestClient { * */ - /** Get all symbols */ - getSymbols(): Promise> { - return this.get('/spot/v3/public/symbols'); - } - - /** Get orderbook for symbol */ - getOrderBook(symbol: string, limit?: number): Promise> { - return this.get('/spot/v3/public/quote/depth', { symbol, limit }); - } - - /** Get merged orderbook for symbol */ + /** + * Get merged orderbook for symbol + * + * This is the only known pre-V5 endpoint to still be online. + */ getMergedOrderBook( symbol: string, scale?: number, @@ -62,246 +44,13 @@ export class SpotClientV3 extends BaseRestClient { }); } - /** Get public trading records (raw trades) */ - getTrades(symbol: string, limit?: number): Promise> { - return this.get('/spot/v3/public/quote/trades', { symbol, limit }); - } - - /** Get candles/klines */ - getCandles( - symbol: string, - interval: KlineInterval, - limit?: number, - startTime?: number, - endTime?: number, - ): Promise> { - return this.get('/spot/v3/public/quote/kline', { - symbol, - interval, - limit, - startTime, - endTime, - }); - } - - /** Get latest information for symbol (24hr ticker) */ - get24hrTicker(symbol?: string): Promise> { - return this.get('/spot/v3/public/quote/ticker/24hr', { symbol }); - } - - /** Get last traded price */ - getLastTradedPrice(symbol?: string): Promise> { - return this.get('/spot/v3/public/quote/ticker/price', { symbol }); - } - - /** Get best bid/ask price */ - getBestBidAskPrice(symbol?: string): Promise> { - return this.get('/spot/v3/public/quote/ticker/bookTicker', { symbol }); - } - - /** - * - * Account Data Endpoints - * - */ - - /** -> Order API */ - - /** Create order */ - submitOrder(params: NewSpotOrderV3): Promise> { - return this.postPrivate('/spot/v3/private/order', params); - } - - /** Get active order state */ - getOrder(params: SpotOrderQueryById): Promise> { - return this.getPrivate('/spot/v3/private/order', params); - } - - /** Cancel order */ - cancelOrder(params: SpotOrderQueryById): Promise> { - return this.postPrivate('/spot/v3/private/cancel-order', params); - } - - /** Batch cancel orders */ - cancelOrderBatch( - params: SpotCancelOrderBatchRequest, - ): Promise> { - const orderTypes = params.orderTypes - ? params.orderTypes.join(',') - : undefined; - - return this.postPrivate('/spot/v3/private/cancel-orders', { - ...params, - orderTypes, - }); - } - - /** Batch cancel up to 100 orders by ID */ - cancelOrderBatchIDs(orderIds: string[]): Promise> { - const orderIdsCsv = orderIds.join(','); - return this.postPrivate('/spot/v3/private/cancel-orders-by-ids', { - orderIds: orderIdsCsv, - }); - } - - /** Get open orders */ - getOpenOrders( - symbol?: string, - orderId?: string, - limit?: number, - orderCategory?: 0 | 1, - ): Promise> { - return this.getPrivate('/spot/v3/private/open-orders', { - symbol, - orderId, - limit, - orderCategory, - }); - } - - /** Get order history */ - getPastOrders( - symbol?: string, - orderId?: string, - limit?: number, - orderCategory?: 0 | 1, - ): Promise> { - return this.getPrivate('/spot/v3/private/history-orders', { - symbol, - orderId, - limit, - orderCategory, - }); - } - - /** - * Get your trade history. - * If startTime is not specified, you can only query for records in the last 7 days. - * If you want to query for records older than 7 days, startTime is required. - */ - getMyTrades(params?: SpotMyTradesRequest): Promise> { - return this.getPrivate('/spot/v3/private/my-trades', params); - } - - /** - * - * Wallet Data Endpoints - * - */ - - /** Get Wallet Balance */ - getBalances(): Promise> { - return this.getPrivate('/spot/v3/private/account'); - } - /** * * API Data Endpoints * */ - getServerTime(): Promise { + getServerTime(): Promise<{ time_now: numberInString }> { return this.get('/v2/public/time'); } - - /** - * - * Leveraged Token Endpoints - * - */ - - /** Get all asset infos */ - getLeveragedTokenAssetInfos(ltCode?: string): Promise> { - return this.get('/spot/v3/public/infos', { ltCode }); - } - - /** Get leveraged token market info */ - getLeveragedTokenMarketInfo(ltCode: string): Promise> { - return this.getPrivate('/spot/v3/private/reference', { ltCode }); - } - - /** Purchase leveraged token */ - purchaseLeveragedToken( - ltCode: string, - ltAmount: string, - serialNo?: string, - ): Promise> { - return this.postPrivate('/spot/v3/private/purchase', { - ltCode, - ltAmount, - serialNo, - }); - } - - /** Redeem leveraged token */ - redeemLeveragedToken( - ltCode: string, - ltAmount: string, - serialNo?: string, - ): Promise> { - return this.postPrivate('/spot/v3/private/redeem', { - ltCode, - ltAmount, - serialNo, - }); - } - - /** Get leveraged token purchase/redemption history */ - getLeveragedTokenPRHistory( - params?: SpotLeveragedTokenPRHistoryRequest, - ): Promise> { - return this.getPrivate('/spot/v3/private/record', params); - } - - /** - * - * Cross Margin Trading Endpoints - * - */ - - /** Borrow margin loan */ - borrowCrossMarginLoan( - coin: string, - qty: string, - ): Promise> { - return this.postPrivate('/spot/v3/private/cross-margin-loan', { - coin, - qty, - }); - } - - /** Repay margin loan */ - repayCrossMarginLoan(coin: string, qty: string): Promise> { - return this.postPrivate('/spot/v3/private/cross-margin-repay', { - coin, - qty, - }); - } - - /** Query borrowing info */ - getCrossMarginBorrowingInfo( - params?: SpotCrossMarginBorrowingInfoRequest, - ): Promise> { - return this.getPrivate('/spot/v3/private/cross-margin-orders', params); - } - - /** Query account info */ - getCrossMarginAccountInfo(): Promise> { - return this.getPrivate('/spot/v3/private/cross-margin-account'); - } - - /** Query interest & quota */ - getCrossMarginInterestQuota(coin: string): Promise> { - return this.getPrivate('/spot/v3/private/cross-margin-loan-info', { coin }); - } - - /** Query repayment history */ - getCrossMarginRepaymentHistory( - params?: SpotCrossMarginRepaymentHistoryRequest, - ): Promise> { - return this.getPrivate( - '/spot/v3/private/cross-margin-repay-history', - params, - ); - } } diff --git a/src/spot-client.ts b/src/spot-client.ts deleted file mode 100644 index c18ae0c..0000000 --- a/src/spot-client.ts +++ /dev/null @@ -1,183 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - APIResponse, - KlineInterval, - NewSpotOrder, - OrderSide, - OrderTypeSpot, - SpotBalances, - SpotLastPrice, - SpotOrderQueryById, - SpotSymbolInfo, -} from './types'; -import BaseRestClient from './util/BaseRestClient'; -import { REST_CLIENT_TYPE_ENUM } from './util/requestUtils'; - -/** - * REST API client for Spot APIs (v1) - * - * @deprecated WARNING: V1/V2 private endpoints (Rest API & Websocket Stream) for mainnet - * will be switched off gradually from 30 Oct 2023 UTC, so they are not promised a stability. - * Please note that you are at your own risk of using old endpoints going forward, and please move to V5 ASAP. - */ -export class SpotClient extends BaseRestClient { - getClientType() { - return REST_CLIENT_TYPE_ENUM.spot; - } - - fetchServerTime(): Promise { - return this.getServerTime(); - } - - async getServerTime(): Promise { - const res = await this.get('/spot/v1/time'); - return res.result.serverTime; - } - - /** - * - * Market Data Endpoints - * - **/ - - getSymbols(): Promise> { - return this.get('/spot/v1/symbols'); - } - - getOrderBook(symbol: string, limit?: number): Promise> { - return this.get('/spot/quote/v1/depth', { - symbol, - limit, - }); - } - - getMergedOrderBook( - symbol: string, - scale?: number, - limit?: number, - ): Promise> { - return this.get('/spot/quote/v1/depth/merged', { - symbol, - scale, - limit, - }); - } - - getTrades(symbol: string, limit?: number): Promise> { - return this.get('/spot/quote/v1/trades', { - symbol, - limit, - }); - } - - getCandles( - symbol: string, - interval: KlineInterval, - limit?: number, - startTime?: number, - endTime?: number, - ): Promise> { - return this.get('/spot/quote/v1/kline', { - symbol, - interval, - limit, - startTime, - endTime, - }); - } - - get24hrTicker(symbol?: string): Promise> { - return this.get('/spot/quote/v1/ticker/24hr', { symbol }); - } - - getLastTradedPrice(): Promise>; - - getLastTradedPrice(symbol: string): Promise>; - - getLastTradedPrice( - symbol?: string, - ): Promise> { - return this.get('/spot/quote/v1/ticker/price', { symbol }); - } - - getBestBidAskPrice(symbol?: string): Promise> { - return this.get('/spot/quote/v1/ticker/book_ticker', { symbol }); - } - - /** - * Account Data Endpoints - */ - - submitOrder(params: NewSpotOrder): Promise> { - return this.postPrivate('/spot/v1/order', params); - } - - getOrder(params: SpotOrderQueryById): Promise> { - return this.getPrivate('/spot/v1/order', params); - } - - cancelOrder(params: SpotOrderQueryById): Promise> { - return this.deletePrivate('/spot/v1/order', params); - } - - cancelOrderBatch(params: { - symbol: string; - side?: OrderSide; - orderTypes: OrderTypeSpot[]; - }): Promise> { - const orderTypes = params.orderTypes - ? params.orderTypes.join(',') - : undefined; - - return this.deletePrivate('/spot/order/batch-cancel', { - ...params, - orderTypes, - }); - } - - getOpenOrders( - symbol?: string, - orderId?: string, - limit?: number, - ): Promise> { - return this.getPrivate('/spot/v1/open-orders', { - symbol, - orderId, - limit, - }); - } - - getPastOrders( - symbol?: string, - orderId?: string, - limit?: number, - ): Promise> { - return this.getPrivate('/spot/v1/history-orders', { - symbol, - orderId, - limit, - }); - } - - getMyTrades( - symbol?: string, - limit?: number, - fromId?: number, - toId?: number, - ): Promise> { - return this.getPrivate('/spot/v1/myTrades', { - symbol, - limit, - fromId, - toId, - }); - } - - /** - * Wallet Data Endpoints - */ - - getBalances(): Promise> { - return this.getPrivate('/spot/v1/account'); - } -} diff --git a/src/types/request/index.ts b/src/types/request/index.ts index 40bd984..1c6b741 100644 --- a/src/types/request/index.ts +++ b/src/types/request/index.ts @@ -3,7 +3,6 @@ export * from './copy-trading'; export * from './contract'; export * from './linear'; export * from './inverse'; -export * from './spot'; export * from './usdc-perp'; export * from './usdc-options'; export * from './usdc-shared'; diff --git a/src/types/request/spot.ts b/src/types/request/spot.ts deleted file mode 100644 index ec90dc1..0000000 --- a/src/types/request/spot.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { OrderSide, numberInString } from '../shared'; - -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 NewSpotOrderV3 { - symbol: string; - orderQty: string; - side: OrderSide; - orderType: OrderTypeSpot; - timeInForce?: OrderTimeInForce; - orderPrice?: string; - orderLinkId?: string; - orderCategory?: 0 | 1; - triggerPrice?: string; -} - -export interface SpotCancelOrderBatchRequest { - symbol: string; - side?: OrderSide; - orderTypes: OrderTypeSpot[]; - orderCategory?: 0 | 1; -} - -export interface SpotOrderQueryById { - orderId?: string; - orderLinkId?: string; - orderCategory?: 0 | 1; -} - -export interface SpotSymbolInfo { - name: string; - alias: string; - baseCurrency: string; - quoteCurrency: string; - basePrecision: numberInString; - quotePrecision: numberInString; - minTradeQuantity: numberInString; - minTradeAmount: numberInString; - minPricePrecision: numberInString; - maxTradeQuantity: numberInString; - maxTradeAmount: numberInString; - category: numberInString; -} - -export interface SpotMyTradesRequest { - symbol?: string; - orderId?: string; - limit?: string; - startTime?: number; - endTime?: number; - fromTradeId?: string; - toTradeId?: string; -} - -export interface SpotLeveragedTokenPRHistoryRequest { - ltCode?: string; - orderId?: string; - startTime?: number; - endTime?: number; - limit?: number; - orderType?: 1 | 2; - serialNo?: string; -} - -export interface SpotCrossMarginBorrowingInfoRequest { - startTime?: number; - endTime?: number; - coin?: string; - status?: 0 | 1 | 2; - limit?: number; -} - -export interface SpotCrossMarginRepaymentHistoryRequest { - startTime?: number; - endTime?: number; - coin?: string; - limit?: number; -} diff --git a/src/types/shared.ts b/src/types/shared.ts index 9be7676..4a25db2 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -1,23 +1,7 @@ -import { ContractClient } from '../contract-client'; -import { InverseClient } from '../inverse-client'; -import { LinearClient } from '../linear-client'; import { RestClientV5 } from '../rest-client-v5'; -import { SpotClient } from '../spot-client'; import { SpotClientV3 } from '../spot-client-v3'; -import { UnifiedMarginClient } from '../unified-margin-client'; -import { USDCOptionClient } from '../usdc-option-client'; -import { USDCPerpetualClient } from '../usdc-perpetual-client'; -export type RESTClient = - | InverseClient - | LinearClient - | SpotClient - | SpotClientV3 - | USDCOptionClient - | USDCPerpetualClient - | UnifiedMarginClient - | ContractClient - | RestClientV5; +export type RESTClient = SpotClientV3 | RestClientV5; export type numberInString = string; @@ -53,14 +37,6 @@ export type KlineIntervalV3 = | 'W' | 'M'; -export interface APIResponse { - ret_code: number; - ret_msg: 'OK' | string; - ext_code: string | null; - ext_info: string | null; - result: T; -} - export interface APIRateLimit { /** Remaining requests to this endpoint before the next reset */ remainingRequests: number; @@ -88,12 +64,6 @@ export interface APIResponseV3 { } export type APIResponseV3WithTime = APIResponseV3 & { time: number }; - -export interface APIResponseWithTime extends APIResponse { - /** UTC timestamp */ - time_now: numberInString; -} - /** * Request Parameter Types */ diff --git a/src/types/websockets/ws-general.ts b/src/types/websockets/ws-general.ts index e156ad9..bd4d52e 100644 --- a/src/types/websockets/ws-general.ts +++ b/src/types/websockets/ws-general.ts @@ -1,18 +1,7 @@ import { RestClientOptions, WS_KEY_MAP } from '../../util'; /** For spot markets, spotV3 is recommended */ -export type APIMarket = - | 'inverse' - | 'linear' - | 'spot' - | 'spotv3' - | 'usdcOption' - | 'usdcPerp' - | 'unifiedPerp' - | 'unifiedOption' - | 'contractUSDT' - | 'contractInverse' - | 'v5'; +export type APIMarket = 'v5'; // Same as inverse futures export type WsPublicInverseTopic = @@ -111,7 +100,7 @@ export interface WSClientConfigurableOptions { /** * The API group this client should connect to. The V5 market is currently used by default. * - * For the V3 APIs use `v3` as the market (spot/unified margin/usdc/account asset/copy trading) + * Only the "V5" "market" is supported here. */ market?: APIMarket; @@ -128,6 +117,7 @@ export interface WSClientConfigurableOptions { reconnectTimeout?: number; restOptions?: RestClientOptions; + // eslint-disable-next-line @typescript-eslint/no-explicit-any requestOptions?: any; diff --git a/src/unified-margin-client.ts b/src/unified-margin-client.ts deleted file mode 100644 index bbfce4a..0000000 --- a/src/unified-margin-client.ts +++ /dev/null @@ -1,401 +0,0 @@ -/* eslint-disable max-len */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - APIResponseV3, - APIResponseWithTime, - InternalTransferRequest, - UM7DayTradingHistoryRequest, - UMActiveOrdersRequest, - UMBatchOrder, - UMBatchOrderCancel, - UMBatchOrderReplace, - UMBorrowHistoryRequest, - UMCancelAllOrdersRequest, - UMCancelOrderRequest, - UMCandlesRequest, - UMCategory, - UMExchangeCoinsRequest, - UMFundingRateHistoryRequest, - UMHistoricOrder, - UMHistoricOrdersRequest, - UMInstrumentInfo, - UMInstrumentInfoRequest, - UMModifyOrderRequest, - UMOpenInterestRequest, - UMOptionDeliveryPriceRequest, - UMOptionsSettlementHistoryRequest, - UMOrderRequest, - UMPaginatedResult, - UMPerpSettlementHistoryRequest, - UMPositionsRequest, - UMPublicTradesRequest, - UMSetTPSLRequest, - UMTransactionLogRequest, -} from './types'; -import { REST_CLIENT_TYPE_ENUM } from './util'; -import BaseRestClient from './util/BaseRestClient'; - -/** - * REST API client for Derivatives V3 unified margin APIs - * @deprecated WARNING - * These endpoints are being switched off gradually and are expected to be completely turned off by the end of 2024. - * They may stop working at any point before then. - * Please update your code as soon as possible to use the V5 APIs instead. - */ -export class UnifiedMarginClient extends BaseRestClient { - getClientType() { - return REST_CLIENT_TYPE_ENUM.v3; - } - - async fetchServerTime(): Promise { - const res = await this.getServerTime(); - return Number(res.time_now); - } - - /** - * - * Market Data Endpoints - * - */ - - /** Query order book info. Each side has a depth of 25 orders. */ - getOrderBook( - symbol: string, - category: string, - limit?: number, - ): Promise> { - return this.get('/derivatives/v3/public/order-book/L2', { - category, - symbol, - limit, - }); - } - - /** Get candles/klines */ - getCandles(params: UMCandlesRequest): Promise> { - return this.get('/derivatives/v3/public/kline', params); - } - - /** Get a symbol price/statistics ticker */ - getSymbolTicker( - category: UMCategory, - symbol?: string, - ): Promise> { - return this.get('/derivatives/v3/public/tickers', { category, symbol }); - } - - /** Get trading rules per symbol/contract, incl price/amount/value/leverage filters */ - getInstrumentInfo( - params: UMInstrumentInfoRequest, - ): Promise>> { - return this.get('/derivatives/v3/public/instruments-info', params); - } - - /** Query mark price kline (like getCandles() but for mark price). */ - getMarkPriceCandles(params: UMCandlesRequest): Promise> { - return this.get('/derivatives/v3/public/mark-price-kline', params); - } - - /** Query Index Price Kline */ - getIndexPriceCandles(params: UMCandlesRequest): Promise> { - return this.get('/derivatives/v3/public/index-price-kline', params); - } - - /** - * The funding rate is generated every 8 hours at 00:00 UTC, 08:00 UTC and 16:00 UTC. - * For example, if a request is sent at 12:00 UTC, the funding rate generated earlier that day at 08:00 UTC will be sent. - */ - getFundingRateHistory( - params: UMFundingRateHistoryRequest, - ): Promise> { - return this.get( - '/derivatives/v3/public/funding/history-funding-rate', - params, - ); - } - - /** Get Risk Limit */ - getRiskLimit( - category: UMCategory, - symbol: string, - ): Promise> { - return this.get('/derivatives/v3/public/risk-limit/list', { - category, - symbol, - }); - } - - /** Get option delivery price */ - getOptionDeliveryPrice( - params: UMOptionDeliveryPriceRequest, - ): Promise> { - return this.get('/derivatives/v3/public/delivery-price', params); - } - - /** Get recent trades */ - getTrades(params: UMPublicTradesRequest): Promise> { - return this.get('/derivatives/v3/public/recent-trade', params); - } - - /** - * Gets the total amount of unsettled contracts. - * In other words, the total number of contracts held in open positions. - */ - getOpenInterest(params: UMOpenInterestRequest): Promise> { - return this.get('/derivatives/v3/public/open-interest', params); - } - - /** - * - * Unified Margin Account Endpoints - * - */ - - /** -> Order API */ - - /** Place an order */ - submitOrder(params: UMOrderRequest): Promise> { - return this.postPrivate('/unified/v3/private/order/create', params); - } - - /** Active order parameters (such as quantity, price) and stop order parameters cannot be modified in one request at the same time. Please request modification separately. */ - modifyOrder(params: UMModifyOrderRequest): Promise> { - return this.postPrivate('/unified/v3/private/order/replace', params); - } - - /** Cancel order */ - cancelOrder(params: UMCancelOrderRequest): Promise> { - return this.postPrivate('/unified/v3/private/order/cancel', params); - } - - /** Query Open Orders */ - getActiveOrders(params: UMActiveOrdersRequest): Promise> { - return this.getPrivate('/unified/v3/private/order/unfilled-orders', params); - } - - /** Query order history. As order creation/cancellation is asynchronous, the data returned from the interface may be delayed. To access order information in real-time, call getActiveOrders() */ - getHistoricOrders( - params: UMHistoricOrdersRequest, - ): Promise>> { - return this.getPrivate('/unified/v3/private/order/list', params); - } - - /** - * This API provides the batch order mode under the unified margin account. - * Max 10 per request - */ - batchSubmitOrders( - category: UMCategory, - orders: UMBatchOrder[], - ): Promise> { - return this.postPrivate('/unified/v3/private/order/create-batch', { - category, - request: orders, - }); - } - - /** - * This interface can modify the open order information in batches. - * Currently, it is not supported to modify the conditional order information. - * Please note that only unfilled or partial filled orders can be modified. - * If both futures and options orders are in one request, only the orders matching the category will be operated according to the category type - */ - batchReplaceOrders( - category: UMCategory, - orders: UMBatchOrderReplace[], - ): Promise> { - return this.postPrivate('/unified/v3/private/order/replace-batch', { - category, - request: orders, - }); - } - - /** - * This API provides batch cancellation under the unified margin account. - * Order cancellation of futures and options cannot be canceled in one request at the same time. - * If both futures and options orders are in one request, only the orders matching the category will be operated according to the category type. - */ - batchCancelOrders( - category: UMCategory, - orders: UMBatchOrderCancel[], - ): Promise> { - return this.postPrivate('/unified/v3/private/order/cancel-batch', { - category, - request: orders, - }); - } - - /** - * This API provides the cancellation of all open orders under the unified margin account. - * Order cancellation of futures and options cannot be canceled in one request at the same time. - * If both futures and options orders are in one request, only the orders matching the category will be operated according to the category type. - */ - cancelAllOrders( - params: UMCancelAllOrdersRequest, - ): Promise> { - return this.postPrivate('/unified/v3/private/order/cancel-all', params); - } - - /** -> Positions API */ - - /** - * Query my positions real-time. Accessing personal list of positions. - * Users can access their position holding information through this interface, such as the number of position holdings and wallet balance. - */ - getPositions(params: UMPositionsRequest): Promise> { - return this.getPrivate('/unified/v3/private/position/list', params); - } - - /** Leverage setting. */ - setLeverage( - category: UMCategory, - symbol: string, - buyLeverage: number, - sellLeverage: number, - ): Promise> { - return this.postPrivate('/unified/v3/private/position/set-leverage', { - category, - symbol, - buyLeverage, - sellLeverage, - }); - } - - /** - * Switching the TP/SL mode to the cross margin mode or selected positions. - * When you set the TP/SL mode on the selected positions, the quantity of take-profit or stop-loss orders can be smaller than the position size. Please use Trading-Stop endpoint. - */ - setTPSLMode( - category: UMCategory, - symbol: string, - tpSlMode: 1 | 0, - ): Promise> { - return this.postPrivate('/unified/v3/private/position/tpsl/switch-mode', { - category, - symbol, - tpSlMode, - }); - } - - /** Set risk limit */ - setRiskLimit( - category: UMCategory, - symbol: string, - riskId: number, - positionIdx: number, - ): Promise> { - return this.postPrivate('/unified/v3/private/position/set-risk-limit', { - category, - symbol, - riskId, - positionIdx, - }); - } - - /** - * Set position TP/SL and trailing stop. - * Pass the following parameters, then the system will create conditional orders. - * If the position is closed, the system will cancel these orders, and adjust the position size. - */ - setTPSL(params: UMSetTPSLRequest): Promise> { - return this.postPrivate( - '/unified/v3/private/position/trading-stop', - params, - ); - } - - /** - * Access the user's filled history, ranked by time in descending order. - * There might be multiple filled histories for an order. - */ - get7DayTradingHistory( - params: UM7DayTradingHistoryRequest, - ): Promise> { - return this.getPrivate('/unified/v3/private/execution/list', params); - } - - /** Query the settlement history, ranked by time in descending order. */ - getOptionsSettlementHistory( - params: UMOptionsSettlementHistoryRequest, - ): Promise> { - return this.getPrivate('/unified/v3/private/delivery-record', params); - } - - /** Query session settlement records, only for USDC perpetual */ - getUSDCPerpetualSettlementHistory( - params: UMPerpSettlementHistoryRequest, - ): Promise> { - return this.getPrivate('/unified/v3/private/settlement-record', params); - } - - /** -> Account API */ - - /** Query wallet balance */ - getBalances(coin?: string): Promise> { - return this.getPrivate('/unified/v3/private/account/wallet/balance', { - coin, - }); - } - - /** - * Upgrade to unified margin account. - * WARNING: This is currently not reversable! - */ - upgradeToUnifiedMargin(): Promise> { - return this.postPrivate( - '/unified/v3/private/account/upgrade-unified-account', - ); - } - - /** Query trading history */ - getTransactionLog( - params: UMTransactionLogRequest, - ): Promise> { - return this.getPrivate( - '/unified/v3/private/account/transaction-log', - params, - ); - } - - /** Fund transfer between accounts (v2) */ - transferFunds(params: InternalTransferRequest): Promise> { - return this.postPrivate('/asset/v1/private/transfer', params); - } - - /** Exchange Coins */ - getCoinExchangeHistory( - params?: UMExchangeCoinsRequest, - ): Promise> { - return this.getPrivate( - '/asset/v2/private/exchange/exchange-order-all', - params, - ); - } - - /** Get Borrow History */ - getBorrowHistory( - params?: UMBorrowHistoryRequest, - ): Promise> { - return this.getPrivate( - '/unified/v3/private/account/borrow-history', - params, - ); - } - - /** Get Borrow Rate */ - getBorrowRate(currency?: string): Promise> { - return this.getPrivate('/unified/v3/private/account/borrow-rate', { - currency, - }); - } - - /** - * - * API Data Endpoints - * - */ - - getServerTime(): Promise { - return this.get('/v2/public/time'); - } -} diff --git a/src/usdc-option-client.ts b/src/usdc-option-client.ts deleted file mode 100644 index 8db52e7..0000000 --- a/src/usdc-option-client.ts +++ /dev/null @@ -1,337 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - APIResponseV3, - APIResponseWithTime, - USDCOptionsActiveOrdersRealtimeRequest, - USDCOptionsActiveOrdersRequest, - USDCOptionsCancelAllOrdersRequest, - USDCOptionsCancelOrderRequest, - USDCOptionsContractInfoRequest, - USDCOptionsDeliveryHistoryRequest, - USDCOptionsDeliveryPriceRequest, - USDCOptionsHistoricOrdersRequest, - USDCOptionsHistoricalVolatilityRequest, - USDCOptionsModifyMMPRequest, - USDCOptionsModifyOrderRequest, - USDCOptionsOrderExecutionRequest, - USDCOptionsOrderRequest, - USDCOptionsPositionsInfoExpiryRequest, - USDCOptionsRecentTradesRequest, - USDCPositionsRequest, - USDCTransactionLogRequest, -} from './types'; -import { REST_CLIENT_TYPE_ENUM } from './util'; -import BaseRestClient from './util/BaseRestClient'; - -/** - * REST API client for USDC Option APIs - * - * @deprecated WARNING: V1/V2 private endpoints (Rest API & Websocket Stream) for mainnet - * will be switched off gradually from 30 Oct 2023 UTC, so they are not promised a stability. - * Please note that you are at your own risk of using old endpoints going forward, and please move to V5 ASAP. - */ -export class USDCOptionClient extends BaseRestClient { - getClientType() { - return REST_CLIENT_TYPE_ENUM.v3; - } - - async fetchServerTime(): Promise { - const res = await this.getServerTime(); - return Number(res.time_now); - } - - /** - * - * Market Data Endpoints - * - */ - - /** Query order book info. Each side has a depth of 25 orders. */ - getOrderBook(symbol: string): Promise> { - return this.get('/option/usdc/openapi/public/v1/order-book', { symbol }); - } - - /** Fetch trading rules (such as min/max qty). Query for all if blank. */ - getContractInfo( - params?: USDCOptionsContractInfoRequest, - ): Promise> { - return this.get('/option/usdc/openapi/public/v1/symbols', params); - } - - /** Get a symbol price/statistics ticker */ - getSymbolTicker(symbol: string): Promise> { - return this.get('/option/usdc/openapi/public/v1/tick', { symbol }); - } - - /** Get delivery information */ - getDeliveryPrice( - params?: USDCOptionsDeliveryPriceRequest, - ): Promise> { - return this.get('/option/usdc/openapi/public/v1/delivery-price', params); - } - - /** Returned records are Taker Buy in default. */ - getLast500Trades( - params: USDCOptionsRecentTradesRequest, - ): Promise> { - return this.get( - '/option/usdc/openapi/public/v1/query-trade-latest', - params, - ); - } - - /** - * The data is in hourly. - * If time field is not passed, it returns the recent 1 hour data by default. - * It could be any timeframe by inputting startTime & endTime, but it must satisfy [endTime - startTime] <= 30 days. - * It returns all data in 2 years when startTime & endTime are not passed. - * Both startTime & endTime entered together or both are left blank - */ - getHistoricalVolatility( - params?: USDCOptionsHistoricalVolatilityRequest, - ): Promise> { - return this.get( - '/option/usdc/openapi/public/v1/query-historical-volatility', - params, - ); - } - - /** - * - * Account Data Endpoints - * - */ - - /** -> Order API */ - - /** - * Place an order using the USDC Derivatives Account. - * The request status can be queried in real-time. - * The response parameters must be queried through a query or a WebSocket response. - */ - submitOrder(params: USDCOptionsOrderRequest): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/place-order', - params, - ); - } - - /** - * Each request supports a max. of four orders. The reduceOnly parameter should be separate and unique for each order in the request. - */ - batchSubmitOrders( - orderRequest: USDCOptionsOrderRequest[], - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/batch-place-orders', - { orderRequest }, - ); - } - - /** For Options, at least one of the three parameters — price, quantity or implied volatility — must be input. */ - modifyOrder( - params: USDCOptionsModifyOrderRequest, - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/replace-order', - params, - ); - } - - /** Each request supports a max. of four orders. The reduceOnly parameter should be separate and unique for each order in the request. */ - batchModifyOrders( - replaceOrderRequest: USDCOptionsModifyOrderRequest[], - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/batch-replace-orders', - { replaceOrderRequest }, - ); - } - - /** Cancel order */ - cancelOrder( - params: USDCOptionsCancelOrderRequest, - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/cancel-order', - params, - ); - } - - /** Batch cancel orders */ - batchCancelOrders( - cancelRequest: USDCOptionsCancelOrderRequest[], - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/batch-cancel-orders', - { cancelRequest }, - ); - } - - /** This is used to cancel all active orders. The real-time response indicates whether the request is successful, depending on retCode. */ - cancelActiveOrders( - params?: USDCOptionsCancelAllOrdersRequest, - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/cancel-all', - params, - ); - } - - /** Query Unfilled/Partially Filled Orders(real-time), up to last 7 days of partially filled/unfilled orders */ - getActiveRealtimeOrders( - params?: USDCOptionsActiveOrdersRealtimeRequest, - ): Promise> { - return this.getPrivate( - '/option/usdc/openapi/private/v1/trade/query-active-orders', - params, - ); - } - - /** Query Unfilled/Partially Filled Orders */ - getActiveOrders( - params: USDCOptionsActiveOrdersRequest, - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-active-orders', - params, - ); - } - - /** Query order history. The endpoint only supports up to 30 days of queried records */ - getHistoricOrders( - params: USDCOptionsHistoricOrdersRequest, - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-order-history', - params, - ); - } - - /** - * Query trade history. - * The endpoint only supports up to 30 days of queried records. - * An error will be returned if startTime is more than 30 days. - */ - getOrderExecutionHistory( - params: USDCOptionsOrderExecutionRequest, - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/execution-list', - params, - ); - } - - /** -> Account API */ - - /** The endpoint only supports up to 30 days of queried records. An error will be returned if startTime is more than 30 days. */ - getTransactionLog( - params: USDCTransactionLogRequest, - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-transaction-log', - params, - ); - } - - /** Wallet info for USDC account. */ - getBalances(): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-wallet-balance', - ); - } - - /** Asset Info */ - getAssetInfo(baseCoin?: string): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-asset-info', - { baseCoin }, - ); - } - - /** - * If USDC derivatives account balance is greater than X, you can open PORTFOLIO_MARGIN, - * and if it is less than Y, it will automatically close PORTFOLIO_MARGIN and change back to REGULAR_MARGIN. - * X and Y will be adjusted according to operational requirements. - * Rest API returns the result of checking prerequisites. You could get the real status of margin mode change by subscribing margin mode. - */ - setMarginMode( - newMarginMode: 'REGULAR_MARGIN' | 'PORTFOLIO_MARGIN', - ): Promise> { - return this.postPrivate( - '/option/usdc/private/asset/account/setMarginMode', - { setMarginMode: newMarginMode }, - ); - } - - /** Query margin mode for USDC account. */ - getMarginMode(): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-margin-info', - ); - } - - /** -> Positions API */ - - /** Query my positions */ - getPositions(params: USDCPositionsRequest): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-position', - params, - ); - } - - /** Query Delivery History */ - getDeliveryHistory( - params: USDCOptionsDeliveryHistoryRequest, - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-delivery-list', - params, - ); - } - - /** Query Positions Info Upon Expiry */ - getPositionsInfoUponExpiry( - params?: USDCOptionsPositionsInfoExpiryRequest, - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-position-exp-date', - params, - ); - } - - /** -> Market Maker Protection */ - - /** modifyMMP */ - modifyMMP(params: USDCOptionsModifyMMPRequest): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/mmp-modify', - params, - ); - } - - /** resetMMP */ - resetMMP(currency: string): Promise> { - return this.postPrivate('/option/usdc/openapi/private/v1/mmp-reset', { - currency, - }); - } - - /** queryMMPState */ - queryMMPState(baseCoin: string): Promise> { - return this.postPrivate('/option/usdc/openapi/private/v1/get-mmp-state', { - baseCoin, - }); - } - - /** - * - * API Data Endpoints - * - */ - - getServerTime(): Promise { - return this.get('/v2/public/time'); - } -} diff --git a/src/usdc-perpetual-client.ts b/src/usdc-perpetual-client.ts deleted file mode 100644 index 0c76819..0000000 --- a/src/usdc-perpetual-client.ts +++ /dev/null @@ -1,320 +0,0 @@ -/* eslint-disable max-len */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - APIResponseV3, - APIResponseWithTime, - SymbolLimitParam, - SymbolPeriodLimitParam, - USDCKlineRequest, - USDCLast500TradesRequest, - USDCOpenInterestRequest, - USDCOrderFilter, - USDCPerpActiveOrdersRequest, - USDCPerpCancelOrderRequest, - USDCPerpHistoricOrdersRequest, - USDCPerpModifyOrderRequest, - USDCPerpOrderRequest, - USDCPositionsRequest, - USDCSymbolDirectionLimit, - USDCSymbolDirectionLimitCursor, - USDCTransactionLogRequest, -} from './types'; -import { REST_CLIENT_TYPE_ENUM } from './util'; -import BaseRestClient from './util/BaseRestClient'; - -/** - * REST API client for USDC Perpetual APIs - * - * @deprecated WARNING: V1/V2 private endpoints (Rest API & Websocket Stream) for mainnet - * will be switched off gradually from 30 Oct 2023 UTC, so they are not promised a stability. - * Please note that you are at your own risk of using old endpoints going forward, and please move to V5 ASAP. - */ -export class USDCPerpetualClient extends BaseRestClient { - getClientType() { - // Follows the same authentication mechanism as other v3 APIs (e.g. USDC) - return REST_CLIENT_TYPE_ENUM.v3; - } - - async fetchServerTime(): Promise { - const res = await this.getServerTime(); - return Number(res.time_now); - } - - /** - * - * Market Data Endpoints - * - */ - - getOrderBook(symbol: string): Promise> { - return this.get('/perpetual/usdc/openapi/public/v1/order-book', { symbol }); - } - - /** Fetch trading rules (such as min/max qty). Query for all if blank. */ - getContractInfo( - params?: USDCSymbolDirectionLimit, - ): Promise> { - return this.get('/perpetual/usdc/openapi/public/v1/symbols', params); - } - - /** Get a symbol price/statistics ticker */ - getSymbolTicker(symbol: string): Promise> { - return this.get('/perpetual/usdc/openapi/public/v1/tick', { symbol }); - } - - getCandles(params: USDCKlineRequest): Promise> { - return this.get('/perpetual/usdc/openapi/public/v1/kline/list', params); - } - - getMarkPrice(params: USDCKlineRequest): Promise> { - return this.get( - '/perpetual/usdc/openapi/public/v1/mark-price-kline', - params, - ); - } - - getIndexPrice(params: USDCKlineRequest): Promise> { - return this.get( - '/perpetual/usdc/openapi/public/v1/index-price-kline', - params, - ); - } - - getIndexPremium(params: USDCKlineRequest): Promise> { - return this.get( - '/perpetual/usdc/openapi/public/v1/premium-index-kline', - params, - ); - } - - getOpenInterest( - params: USDCOpenInterestRequest, - ): Promise> { - return this.get('/perpetual/usdc/openapi/public/v1/open-interest', params); - } - - getLargeOrders( - params: SymbolLimitParam, - ): Promise> { - return this.get('/perpetual/usdc/openapi/public/v1/big-deal', params); - } - - getLongShortRatio( - params: SymbolPeriodLimitParam, - ): Promise> { - return this.get('/perpetual/usdc/openapi/public/v1/account-ratio', params); - } - - getLast500Trades( - params: USDCLast500TradesRequest, - ): Promise> { - return this.get( - '/option/usdc/openapi/public/v1/query-trade-latest', - params, - ); - } - - /** - * - * Account Data Endpoints - * - */ - - /** -> Order API */ - - /** - * Place an order using the USDC Derivatives Account. - * The request status can be queried in real-time. - * The response parameters must be queried through a query or a WebSocket response. - */ - submitOrder(params: USDCPerpOrderRequest): Promise> { - return this.postPrivate( - '/perpetual/usdc/openapi/private/v1/place-order', - params, - ); - } - - /** - * Active order parameters (such as quantity, price) and stop order parameters cannot be modified in one request at the same time. - * Please request modification separately. - */ - modifyOrder(params: USDCPerpModifyOrderRequest): Promise> { - return this.postPrivate( - '/perpetual/usdc/openapi/private/v1/replace-order', - params, - ); - } - - /** Cancel order */ - cancelOrder(params: USDCPerpCancelOrderRequest): Promise> { - return this.postPrivate( - '/perpetual/usdc/openapi/private/v1/cancel-order', - params, - ); - } - - /** Cancel all active orders. The real-time response indicates whether the request is successful, depending on retCode. */ - cancelActiveOrders( - symbol: string, - orderFilter: USDCOrderFilter, - ): Promise> { - return this.postPrivate('/perpetual/usdc/openapi/private/v1/cancel-all', { - symbol, - orderFilter, - }); - } - - /** Query Unfilled/Partially Filled Orders */ - getActiveOrders( - params: USDCPerpActiveOrdersRequest, - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-active-orders', - params, - ); - } - - /** Query order history. The endpoint only supports up to 30 days of queried records */ - getHistoricOrders( - params: USDCPerpHistoricOrdersRequest, - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-order-history', - params, - ); - } - - /** Query trade history. The endpoint only supports up to 30 days of queried records. An error will be returned if startTime is more than 30 days. */ - getOrderExecutionHistory( - params: USDCPerpActiveOrdersRequest, - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/execution-list', - params, - ); - } - - /** -> Account API */ - - /** The endpoint only supports up to 30 days of queried records. An error will be returned if startTime is more than 30 days. */ - getTransactionLog( - params: USDCTransactionLogRequest, - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-transaction-log', - params, - ); - } - - /** Wallet info for USDC account. */ - getBalances(): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-wallet-balance', - ); - } - - /** Asset Info */ - getAssetInfo(baseCoin?: string): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-asset-info', - { baseCoin }, - ); - } - - /** - * If USDC derivatives account balance is greater than X, you can open PORTFOLIO_MARGIN, - * and if it is less than Y, it will automatically close PORTFOLIO_MARGIN and change back to REGULAR_MARGIN. - * X and Y will be adjusted according to operational requirements. - * Rest API returns the result of checking prerequisites. You could get the real status of margin mode change by subscribing margin mode. - */ - setMarginMode( - newMarginMode: 'REGULAR_MARGIN' | 'PORTFOLIO_MARGIN', - ): Promise> { - return this.postPrivate( - '/option/usdc/private/asset/account/setMarginMode', - { setMarginMode: newMarginMode }, - ); - } - - /** Query margin mode for USDC account. */ - getMarginMode(): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-margin-info', - ); - } - - /** -> Positions API */ - - /** Query my positions */ - getPositions(params: USDCPositionsRequest): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/query-position', - params, - ); - } - - /** Only for REGULAR_MARGIN */ - setLeverage(symbol: string, leverage: string): Promise> { - return this.postPrivate( - '/perpetual/usdc/openapi/private/v1/position/leverage/save', - { symbol, leverage }, - ); - } - - /** Query Settlement History */ - getSettlementHistory( - params?: USDCSymbolDirectionLimitCursor, - ): Promise> { - return this.postPrivate( - '/option/usdc/openapi/private/v1/session-settlement', - params, - ); - } - - /** -> Risk Limit API */ - - /** Query risk limit */ - getRiskLimit(symbol: string): Promise> { - return this.getPrivate( - '/perpetual/usdc/openapi/public/v1/risk-limit/list', - { - symbol, - }, - ); - } - - /** Set risk limit */ - setRiskLimit(symbol: string, riskId: number): Promise> { - return this.postPrivate( - '/perpetual/usdc/openapi/private/v1/position/set-risk-limit', - { symbol, riskId }, - ); - } - - /** -> Funding API */ - - /** Funding settlement occurs every 8 hours at 00:00 UTC, 08:00 UTC and 16:00 UTC. The current interval's fund fee settlement is based on the previous interval's fund rate. For example, at 16:00, the settlement is based on the fund rate generated at 8:00. The fund rate generated at 16:00 will be used at 0:00 the next day. */ - getLastFundingRate(symbol: string): Promise> { - return this.get('/perpetual/usdc/openapi/public/v1/prev-funding-rate', { - symbol, - }); - } - - /** Get predicted funding rate and my predicted funding fee */ - getPredictedFundingRate(symbol: string): Promise> { - return this.postPrivate( - '/perpetual/usdc/openapi/private/v1/predicted-funding', - { symbol }, - ); - } - - /** - * - * API Data Endpoints - * - */ - - getServerTime(): Promise { - return this.get('/v2/public/time'); - } -} diff --git a/src/util/websockets/websocket-util.ts b/src/util/websockets/websocket-util.ts index 1454a31..883d14c 100644 --- a/src/util/websockets/websocket-util.ts +++ b/src/util/websockets/websocket-util.ts @@ -13,25 +13,6 @@ import { WSAPIRequest } from '../../types/websockets/ws-api'; export const WS_LOGGER_CATEGORY = { category: 'bybit-ws' }; export const WS_KEY_MAP = { - inverse: 'inverse', - linearPrivate: 'linearPrivate', - linearPublic: 'linearPublic', - spotPrivate: 'spotPrivate', - spotPublic: 'spotPublic', - spotV3Private: 'spotV3Private', - spotV3Public: 'spotV3Public', - usdcOptionPrivate: 'usdcOptionPrivate', - usdcOptionPublic: 'usdcOptionPublic', - usdcPerpPrivate: 'usdcPerpPrivate', - usdcPerpPublic: 'usdcPerpPublic', - unifiedPrivate: 'unifiedPrivate', - unifiedOptionPublic: 'unifiedOptionPublic', - unifiedPerpUSDTPublic: 'unifiedPerpUSDTPublic', - unifiedPerpUSDCPublic: 'unifiedPerpUSDCPublic', - contractUSDTPublic: 'contractUSDTPublic', - contractUSDTPrivate: 'contractUSDTPrivate', - contractInversePublic: 'contractInversePublic', - contractInversePrivate: 'contractInversePrivate', v5SpotPublic: 'v5SpotPublic', v5LinearPublic: 'v5LinearPublic', v5InversePublic: 'v5InversePublic', @@ -44,27 +25,11 @@ export const WS_KEY_MAP = { } as const; export const WS_AUTH_ON_CONNECT_KEYS: WsKey[] = [ - WS_KEY_MAP.spotV3Private, - WS_KEY_MAP.usdcOptionPrivate, - WS_KEY_MAP.usdcPerpPrivate, - WS_KEY_MAP.unifiedPrivate, - WS_KEY_MAP.contractUSDTPrivate, - WS_KEY_MAP.contractInversePrivate, WS_KEY_MAP.v5Private, WS_KEY_MAP.v5PrivateTrade, ]; export const PUBLIC_WS_KEYS = [ - WS_KEY_MAP.linearPublic, - WS_KEY_MAP.spotPublic, - WS_KEY_MAP.spotV3Public, - WS_KEY_MAP.usdcOptionPublic, - WS_KEY_MAP.usdcPerpPublic, - WS_KEY_MAP.unifiedOptionPublic, - WS_KEY_MAP.unifiedPerpUSDTPublic, - WS_KEY_MAP.unifiedPerpUSDCPublic, - WS_KEY_MAP.contractUSDTPublic, - WS_KEY_MAP.contractInversePublic, WS_KEY_MAP.v5SpotPublic, WS_KEY_MAP.v5LinearPublic, WS_KEY_MAP.v5InversePublic, @@ -157,8 +122,6 @@ type PublicPrivateNetwork = 'public' | 'private'; * For the v5 endpoints, the subscribe/unsubscribe call must specify the category the subscription should route to. */ type PublicOnlyWsKeys = - | 'unifiedPerpUSDT' - | 'unifiedPerpUSDC' | 'v5SpotPublic' | 'v5LinearPublic' | 'v5InversePublic' @@ -217,126 +180,6 @@ export const WS_BASE_URL_MAP: Record< testnet: 'wss://stream-testnet.bybit.com/v5/public/option', }, }, - inverse: { - public: { - livenet: 'wss://stream.bybit.com/realtime', - testnet: 'wss://stream-testnet.bybit.com/realtime', - }, - private: { - livenet: 'wss://stream.bybit.com/realtime', - testnet: 'wss://stream-testnet.bybit.com/realtime', - }, - }, - linear: { - public: { - livenet: 'wss://stream.bybit.com/realtime_public', - livenet2: 'wss://stream.bytick.com/realtime_public', - testnet: 'wss://stream-testnet.bybit.com/realtime_public', - }, - private: { - livenet: 'wss://stream.bybit.com/realtime_private', - livenet2: 'wss://stream.bytick.com/realtime_private', - testnet: 'wss://stream-testnet.bybit.com/realtime_private', - }, - }, - spot: { - 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', - }, - private: { - livenet: 'wss://stream.bybit.com/spot/ws', - testnet: 'wss://stream-testnet.bybit.com/spot/ws', - }, - }, - spotv3: { - public: { - livenet: 'wss://stream.bybit.com/spot/public/v3', - testnet: 'wss://stream-testnet.bybit.com/spot/public/v3', - }, - private: { - livenet: 'wss://stream.bybit.com/spot/private/v3', - testnet: 'wss://stream-testnet.bybit.com/spot/private/v3', - }, - }, - usdcOption: { - public: { - livenet: 'wss://stream.bybit.com/trade/option/usdc/public/v1', - livenet2: 'wss://stream.bytick.com/trade/option/usdc/public/v1', - testnet: 'wss://stream-testnet.bybit.com/trade/option/usdc/public/v1', - }, - private: { - livenet: 'wss://stream.bybit.com/trade/option/usdc/private/v1', - livenet2: 'wss://stream.bytick.com/trade/option/usdc/private/v1', - testnet: 'wss://stream-testnet.bybit.com/trade/option/usdc/private/v1', - }, - }, - usdcPerp: { - public: { - livenet: 'wss://stream.bybit.com/perpetual/ws/v1/realtime_public', - livenet2: 'wss://stream.bytick.com/perpetual/ws/v1/realtime_public', - testnet: 'wss://stream-testnet.bybit.com/perpetual/ws/v1/realtime_public', - }, - private: { - livenet: 'wss://stream.bybit.com/trade/option/usdc/private/v1', - livenet2: 'wss://stream.bytick.com/trade/option/usdc/private/v1', - testnet: 'wss://stream-testnet.bybit.com/trade/option/usdc/private/v1', - }, - }, - unifiedOption: { - public: { - livenet: 'wss://stream.bybit.com/option/usdc/public/v3', - testnet: 'wss://stream-testnet.bybit.com/option/usdc/public/v3', - }, - private: { - livenet: 'wss://stream.bybit.com/unified/private/v3', - testnet: 'wss://stream-testnet.bybit.com/unified/private/v3', - }, - }, - unifiedPerp: { - public: { - livenet: 'useBaseSpecificEndpoint', - testnet: 'useBaseSpecificEndpoint', - }, - private: { - livenet: 'wss://stream.bybit.com/unified/private/v3', - testnet: 'wss://stream-testnet.bybit.com/unified/private/v3', - }, - }, - unifiedPerpUSDT: { - public: { - livenet: 'wss://stream.bybit.com/contract/usdt/public/v3', - testnet: 'wss://stream-testnet.bybit.com/contract/usdt/public/v3', - }, - }, - unifiedPerpUSDC: { - public: { - livenet: 'wss://stream.bybit.com/contract/usdc/public/v3', - testnet: 'wss://stream-testnet.bybit.com/contract/usdc/public/v3', - }, - }, - contractUSDT: { - public: { - livenet: 'wss://stream.bybit.com/contract/usdt/public/v3', - testnet: 'wss://stream-testnet.bybit.com/contract/usdt/public/v3', - }, - private: { - livenet: 'wss://stream.bybit.com/contract/private/v3', - testnet: 'wss://stream-testnet.bybit.com/contract/private/v3', - }, - }, - contractInverse: { - public: { - livenet: 'wss://stream.bybit.com/contract/inverse/public/v3', - testnet: 'wss://stream-testnet.bybit.com/contract/inverse/public/v3', - }, - private: { - livenet: 'wss://stream.bybit.com/contract/private/v3', - testnet: 'wss://stream-testnet.bybit.com/contract/private/v3', - }, - }, }; export function isPrivateWsTopic(topic: string): boolean { @@ -351,68 +194,6 @@ export function getWsKeyForTopic( ): WsKey { const isPrivateTopic = isPrivate === true || PRIVATE_TOPICS.includes(topic); switch (market) { - case 'inverse': { - return WS_KEY_MAP.inverse; - } - case 'linear': { - return isPrivateTopic - ? WS_KEY_MAP.linearPrivate - : WS_KEY_MAP.linearPublic; - } - case 'spot': { - return isPrivateTopic ? WS_KEY_MAP.spotPrivate : WS_KEY_MAP.spotPublic; - } - case 'spotv3': { - return isPrivateTopic - ? WS_KEY_MAP.spotV3Private - : WS_KEY_MAP.spotV3Public; - } - case 'usdcOption': { - return isPrivateTopic - ? WS_KEY_MAP.usdcOptionPrivate - : WS_KEY_MAP.usdcOptionPublic; - } - case 'usdcPerp': { - return isPrivateTopic - ? WS_KEY_MAP.usdcPerpPrivate - : WS_KEY_MAP.usdcPerpPublic; - } - case 'unifiedOption': { - return isPrivateTopic - ? WS_KEY_MAP.unifiedPrivate - : WS_KEY_MAP.unifiedOptionPublic; - } - case 'unifiedPerp': { - if (isPrivateTopic) { - return WS_KEY_MAP.unifiedPrivate; - } - - const upperTopic = topic.toUpperCase(); - if (upperTopic.indexOf('USDT') !== -1) { - return WS_KEY_MAP.unifiedPerpUSDTPublic; - } - - if ( - upperTopic.indexOf('USDC') !== -1 || - upperTopic.indexOf('PERP') !== -1 - ) { - return WS_KEY_MAP.unifiedPerpUSDCPublic; - } - - throw new Error( - `Failed to determine wskey for unified perps topic: "${topic}"`, - ); - } - case 'contractInverse': { - return isPrivateTopic - ? WS_KEY_MAP.contractInversePrivate - : WS_KEY_MAP.contractInversePublic; - } - case 'contractUSDT': { - return isPrivateTopic - ? WS_KEY_MAP.contractUSDTPrivate - : WS_KEY_MAP.contractUSDTPublic; - } case 'v5': { if (isPrivateTopic) { return WS_KEY_MAP.v5Private; @@ -486,64 +267,6 @@ export function getWsUrl( case WS_KEY_MAP.v5OptionPublic: { return WS_BASE_URL_MAP.v5OptionPublic.public[networkKey]; } - case WS_KEY_MAP.linearPublic: { - return WS_BASE_URL_MAP.linear.public[networkKey]; - } - case WS_KEY_MAP.linearPrivate: { - return WS_BASE_URL_MAP.linear.private[networkKey]; - } - case WS_KEY_MAP.spotPublic: { - return WS_BASE_URL_MAP.spot.public[networkKey]; - } - case WS_KEY_MAP.spotPrivate: { - return WS_BASE_URL_MAP.spot.private[networkKey]; - } - case WS_KEY_MAP.spotV3Public: { - return WS_BASE_URL_MAP.spotv3.public[networkKey]; - } - case WS_KEY_MAP.spotV3Private: { - return WS_BASE_URL_MAP.spotv3.private[networkKey]; - } - case WS_KEY_MAP.inverse: { - // private and public are on the same WS connection - return WS_BASE_URL_MAP.inverse.public[networkKey]; - } - case WS_KEY_MAP.usdcOptionPublic: { - return WS_BASE_URL_MAP.usdcOption.public[networkKey]; - } - case WS_KEY_MAP.usdcOptionPrivate: { - return WS_BASE_URL_MAP.usdcOption.private[networkKey]; - } - case WS_KEY_MAP.usdcPerpPublic: { - return WS_BASE_URL_MAP.usdcPerp.public[networkKey]; - } - case WS_KEY_MAP.usdcPerpPrivate: { - return WS_BASE_URL_MAP.usdcPerp.private[networkKey]; - } - case WS_KEY_MAP.unifiedOptionPublic: { - return WS_BASE_URL_MAP.unifiedOption.public[networkKey]; - } - case WS_KEY_MAP.unifiedPerpUSDTPublic: { - return WS_BASE_URL_MAP.unifiedPerpUSDT.public[networkKey]; - } - case WS_KEY_MAP.unifiedPerpUSDCPublic: { - return WS_BASE_URL_MAP.unifiedPerpUSDC.public[networkKey]; - } - case WS_KEY_MAP.unifiedPrivate: { - return WS_BASE_URL_MAP.unifiedPerp.private[networkKey]; - } - case WS_KEY_MAP.contractInversePrivate: { - return WS_BASE_URL_MAP.contractInverse.private[networkKey]; - } - case WS_KEY_MAP.contractInversePublic: { - return WS_BASE_URL_MAP.contractInverse.public[networkKey]; - } - case WS_KEY_MAP.contractUSDTPrivate: { - return WS_BASE_URL_MAP.contractUSDT.private[networkKey]; - } - case WS_KEY_MAP.contractUSDTPublic: { - return WS_BASE_URL_MAP.contractUSDT.public[networkKey]; - } default: { logger.error('getWsUrl(): Unhandled wsKey: ', { category: 'bybit-ws', @@ -558,50 +281,19 @@ export function getMaxTopicsPerSubscribeEvent( market: APIMarket, wsKey: WsKey, ): number | null { - const topicsPerEventSpot = 10; switch (market) { - case 'inverse': - case 'linear': - case 'usdcOption': - case 'usdcPerp': - case 'unifiedOption': - case 'unifiedPerp': - case 'spot': - case 'contractInverse': - case 'contractUSDT': case 'v5': { if (wsKey === WS_KEY_MAP.v5SpotPublic) { - return topicsPerEventSpot; + return 10; } return null; } - case 'spotv3': { - return topicsPerEventSpot; - } default: { throw neverGuard(market, 'getWsKeyForTopic(): Unhandled market'); } } } -export function getUsdcWsKeyForTopic( - topic: string, - subGroup: 'option' | 'perp', -): WsKey { - const isPrivateTopic = PRIVATE_TOPICS.includes(topic); - if (subGroup === 'option') { - return isPrivateTopic - ? WS_KEY_MAP.usdcOptionPrivate - : WS_KEY_MAP.usdcOptionPublic; - } - return isPrivateTopic - ? WS_KEY_MAP.usdcOptionPrivate - : WS_KEY_MAP.usdcOptionPublic; - // return isPrivateTopic - // ? WS_KEY_MAP.usdcPerpPrivate - // : WS_KEY_MAP.usdcPerpPublic; -} - export const WS_ERROR_ENUM = { NOT_AUTHENTICATED_SPOT_V3: '-1004', API_ERROR_GENERIC: '10001', @@ -610,7 +302,7 @@ export const WS_ERROR_ENUM = { }; export function neverGuard(x: never, msg: string): Error { - return new Error(`Unhandled value exception "x", ${msg}`); + return new Error(`Unhandled value exception "${x}", ${msg}`); } /** @@ -670,6 +362,7 @@ export function getNormalisedTopicRequests( * @returns */ export function getTopicsPerWSKey( + market: APIMarket, normalisedTopicRequests: WsTopicRequest[], wsKey?: WsKey, isPrivateTopic?: boolean, @@ -683,7 +376,7 @@ export function getTopicsPerWSKey( const derivedWsKey = wsKey || getWsKeyForTopic( - this.options.market, + market, topicRequest.topic, isPrivateTopic, topicRequest.category, diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 07d1288..3ed2ccf 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -50,28 +50,13 @@ export class WebsocketClient extends BaseWebsocketClient< WsRequestOperationBybit > { /** - * Request connection of all dependent (public & private) websockets, instead of waiting for automatic connection by library + * Request connection of all dependent (public & private) websockets, instead of waiting + * for automatic connection by SDK. */ public connectAll(): Promise[] { switch (this.options.market) { - case 'inverse': { - // only one for inverse - return [...this.connectPublic()]; - } - // these all have separate public & private ws endpoints - case 'linear': - case 'spot': - case 'spotv3': - case 'usdcOption': - case 'usdcPerp': - case 'unifiedPerp': - case 'unifiedOption': - case 'contractUSDT': - case 'contractInverse': { - return [...this.connectPublic(), this.connectPrivate()]; - } case 'v5': { - return [this.connectPrivate()]; + return [...this.connectPublic(), this.connectPrivate()]; } default: { throw neverGuard(this.options.market, 'connectAll(): Unhandled market'); @@ -101,37 +86,6 @@ export class WebsocketClient extends BaseWebsocketClient< this.connect(WS_KEY_MAP.v5OptionPublic), ]; } - case 'inverse': { - return [this.connect(WS_KEY_MAP.inverse)]; - } - case 'linear': { - return [this.connect(WS_KEY_MAP.linearPublic)]; - } - case 'spot': { - return [this.connect(WS_KEY_MAP.spotPublic)]; - } - case 'spotv3': { - return [this.connect(WS_KEY_MAP.spotV3Public)]; - } - case 'usdcOption': { - return [this.connect(WS_KEY_MAP.usdcOptionPublic)]; - } - case 'usdcPerp': { - return [this.connect(WS_KEY_MAP.usdcPerpPublic)]; - } - case 'unifiedOption': { - return [this.connect(WS_KEY_MAP.unifiedOptionPublic)]; - } - case 'unifiedPerp': { - return [ - this.connect(WS_KEY_MAP.unifiedPerpUSDTPublic), - this.connect(WS_KEY_MAP.unifiedPerpUSDCPublic), - ]; - } - case 'contractUSDT': - return [this.connect(WS_KEY_MAP.contractUSDTPublic)]; - case 'contractInverse': - return [this.connect(WS_KEY_MAP.contractInversePublic)]; } } @@ -141,40 +95,16 @@ export class WebsocketClient extends BaseWebsocketClient< default: { return this.connect(WS_KEY_MAP.v5Private); } - case 'inverse': { - return this.connect(WS_KEY_MAP.inverse); - } - case 'linear': { - return this.connect(WS_KEY_MAP.linearPrivate); - } - case 'spot': { - return this.connect(WS_KEY_MAP.spotPrivate); - } - case 'spotv3': { - return this.connect(WS_KEY_MAP.spotV3Private); - } - case 'usdcOption': { - return this.connect(WS_KEY_MAP.usdcOptionPrivate); - } - case 'usdcPerp': { - return this.connect(WS_KEY_MAP.usdcPerpPrivate); - } - case 'unifiedPerp': - case 'unifiedOption': { - return this.connect(WS_KEY_MAP.unifiedPrivate); - } - case 'contractUSDT': - return this.connect(WS_KEY_MAP.contractUSDTPrivate); - case 'contractInverse': - return this.connect(WS_KEY_MAP.contractInversePrivate); } } /** * Subscribe to V5 topics & track/persist them. * @param wsTopics - topic or list of topics - * @param category - the API category this topic is for (e.g. "linear"). The value is only important when connecting to public topics and will be ignored for private topics. - * @param isPrivateTopic - optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) + * @param category - the API category this topic is for (e.g. "linear"). + * The value is only important when connecting to public topics and will be ignored for private topics. + * @param isPrivateTopic - optional - the library will try to detect private topics, you can use this + * to mark a topic as private (if the topic isn't recognised yet) */ public subscribeV5( wsTopics: WsTopic[] | WsTopic, @@ -233,10 +163,14 @@ export class WebsocketClient extends BaseWebsocketClient< } /** - * Unsubscribe from V5 topics & remove them from memory. They won't be re-subscribed to if the connection reconnects. + * Unsubscribe from V5 topics & remove them from memory. They won't be re-subscribed to if the + * connection reconnects. + * * @param wsTopics - topic or list of topics - * @param category - the API category this topic is for (e.g. "linear"). The value is only important when connecting to public topics and will be ignored for private topics. - * @param isPrivateTopic - optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) + * @param category - the API category this topic is for (e.g. "linear"). The value is only + * important when connecting to public topics and will be ignored for private topics. + * @param isPrivateTopic - optional - the library will try to detect private topics, you can + * use this to mark a topic as private (if the topic isn't recognised yet) */ public unsubscribeV5( wsTopics: WsTopic[] | WsTopic, @@ -297,7 +231,9 @@ export class WebsocketClient extends BaseWebsocketClient< /** * Note: subscribeV5() might be simpler to use. The end result is the same. * - * Request subscription to one or more topics. Pass topics as either an array of strings, or array of objects (if the topic has parameters). + * Request subscription to one or more topics. Pass topics as either an array of strings, + * or array of objects (if the topic has parameters). + * * Objects should be formatted as {topic: string, params: object, category: CategoryV5}. * * - Subscriptions are automatically routed to the correct websocket connection. @@ -315,7 +251,11 @@ export class WebsocketClient extends BaseWebsocketClient< const topicRequests = Array.isArray(requests) ? requests : [requests]; const normalisedTopicRequests = getNormalisedTopicRequests(topicRequests); - const perWsKeyTopics = getTopicsPerWSKey(normalisedTopicRequests, wsKey); + const perWsKeyTopics = getTopicsPerWSKey( + this.options.market, + normalisedTopicRequests, + wsKey, + ); // Batch sub topics per ws key for (const wsKey in perWsKeyTopics) { @@ -342,7 +282,11 @@ export class WebsocketClient extends BaseWebsocketClient< const topicRequests = Array.isArray(requests) ? requests : [requests]; const normalisedTopicRequests = getNormalisedTopicRequests(topicRequests); - const perWsKeyTopics = getTopicsPerWSKey(normalisedTopicRequests, wsKey); + const perWsKeyTopics = getTopicsPerWSKey( + this.options.market, + normalisedTopicRequests, + wsKey, + ); // Batch sub topics per ws key for (const wsKey in perWsKeyTopics) { @@ -374,11 +318,14 @@ export class WebsocketClient extends BaseWebsocketClient< * - an exception is detected in the reply, OR * - the connection disconnects for any reason (even if automatic reconnect will happen). * - * Authentication is automatic. If you didn't request authentication yourself, there might be a small delay after your first request, while the SDK automatically authenticates. + * Authentication is automatic. If you didn't request authentication yourself, there might + * be a small delay after your first request, while the SDK automatically authenticates. * - * @param wsKey - The connection this event is for. Currently only "v5PrivateTrade" is supported for Bybit, since that is the dedicated WS API connection. + * @param wsKey - The connection this event is for. Currently only "v5PrivateTrade" is supported + * for Bybit, since that is the dedicated WS API connection. * @param operation - The command being sent, e.g. "order.create" to submit a new order. - * @param params - Any request parameters for the command. E.g. `OrderParamsV5` to submit a new order. Only send parameters for the request body. Everything else is automatically handled. + * @param params - Any request parameters for the command. E.g. `OrderParamsV5` to submit a new + * order. Only send parameters for the request body. Everything else is automatically handled. * @returns Promise - tries to resolve with async WS API response. Rejects if disconnected or exception is seen in async WS API response */ @@ -393,8 +340,10 @@ export class WebsocketClient extends BaseWebsocketClient< ...params: TWSParams extends undefined ? [] : [TWSParams] ): Promise; - // These overloads give stricter types than mapped generics, since generic constraints do not trigger excess property checks - // Without these overloads, TypeScript won't complain if you include an unexpected property with your request (if it doesn't clash with an existing property) + // These overloads give stricter types than mapped generics, since generic constraints + // do not trigger excess property checks + // Without these overloads, TypeScript won't complain if you include an + // unexpected property with your request (if it doesn't clash with an existing property) sendWSAPIRequest( wsKey: typeof WS_KEY_MAP.v5PrivateTrade, operation: TWSOpreation, @@ -483,7 +432,7 @@ export class WebsocketClient extends BaseWebsocketClient< const wsBaseURL = getWsUrl(wsKey, this.options, this.logger); // If auth is needed for this wsKey URL, this returns a suffix - const authParams = await this.getWsAuthURLSuffix(wsKey); + const authParams = await this.getWsAuthURLSuffix(); if (!authParams) { return wsBaseURL; } @@ -494,8 +443,7 @@ export class WebsocketClient extends BaseWebsocketClient< /** * Return params required to make authorized request */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - private async getWsAuthURLSuffix(wsKey: WsKey): Promise { + private async getWsAuthURLSuffix(): Promise { return ''; } @@ -581,7 +529,6 @@ export class WebsocketClient extends BaseWebsocketClient< } /** Force subscription requests to be sent in smaller batches, if a number is returned */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars protected getMaxTopicsPerSubscribeEvent(wsKey: WsKey): number | null { return getMaxTopicsPerSubscribeEvent(this.options.market, wsKey); } @@ -648,17 +595,7 @@ export class WebsocketClient extends BaseWebsocketClient< } protected getPrivateWSKeys(): WsKey[] { - return [ - WS_KEY_MAP.linearPrivate, - WS_KEY_MAP.spotPrivate, - WS_KEY_MAP.spotV3Private, - WS_KEY_MAP.usdcOptionPrivate, - WS_KEY_MAP.usdcPerpPrivate, - WS_KEY_MAP.unifiedPrivate, - WS_KEY_MAP.contractUSDTPrivate, - WS_KEY_MAP.contractInversePrivate, - WS_KEY_MAP.v5Private, - ]; + return WS_AUTH_ON_CONNECT_KEYS; } protected isAuthOnConnectWsKey(wsKey: WsKey): boolean { @@ -668,11 +605,7 @@ export class WebsocketClient extends BaseWebsocketClient< /** * Determines if a topic is for a private channel, using a hardcoded list of strings */ - protected isPrivateTopicRequest( - request: WsTopicRequest, - // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars - wsKey: WsKey, - ): boolean { + protected isPrivateTopicRequest(request: WsTopicRequest): boolean { const topicName = request?.topic?.toLowerCase(); if (!topicName) { return false; From 03169b324ce4e051981ede452034222b89c91135 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Fri, 24 Jan 2025 15:28:28 +0000 Subject: [PATCH 29/60] chore(): deprecate pre-V5 authentication workflows --- src/rest-client-v5.ts | 2 +- src/spot-client-v3.ts | 4 +- src/util/BaseRestClient.ts | 62 +++++++-------------------- src/util/requestUtils.ts | 28 +----------- src/util/typeGuards.ts | 28 ++++++++++++ src/util/webCryptoAPI.ts | 2 +- src/util/websockets/websocket-util.ts | 5 +-- 7 files changed, 50 insertions(+), 81 deletions(-) diff --git a/src/rest-client-v5.ts b/src/rest-client-v5.ts index 0262485..0913f3e 100644 --- a/src/rest-client-v5.ts +++ b/src/rest-client-v5.ts @@ -272,7 +272,7 @@ export class RestClientV5 extends BaseRestClient { */ getClientType() { - return REST_CLIENT_TYPE_ENUM.v3; + return REST_CLIENT_TYPE_ENUM.v5; } async fetchServerTime(): Promise { diff --git a/src/spot-client-v3.ts b/src/spot-client-v3.ts index 8c8d0f4..0ccf887 100644 --- a/src/spot-client-v3.ts +++ b/src/spot-client-v3.ts @@ -12,8 +12,8 @@ import BaseRestClient from './util/BaseRestClient'; */ export class SpotClientV3 extends BaseRestClient { getClientType() { - // Follows the same authentication mechanism as other v3 APIs (e.g. USDC) - return REST_CLIENT_TYPE_ENUM.v3; + // Doesn't really matter here, since the only remaining endpoint does not require auth. + return REST_CLIENT_TYPE_ENUM.v5; } async fetchServerTime(): Promise { diff --git a/src/util/BaseRestClient.ts b/src/util/BaseRestClient.ts index 5b43ae2..2309ec9 100644 --- a/src/util/BaseRestClient.ts +++ b/src/util/BaseRestClient.ts @@ -164,10 +164,6 @@ export default abstract class BaseRestClient { } } - private isSpotV1Client() { - return this.clientType === REST_CLIENT_TYPE_ENUM.spot; - } - get(endpoint: string, params?: any) { return this._call('GET', endpoint, params, true); } @@ -263,56 +259,34 @@ export default abstract class BaseRestClient { }; } - // USDC endpoints, unified margin and a few others use a different way of authenticating requests (headers instead of params) - if (this.clientType === REST_CLIENT_TYPE_ENUM.v3) { - const signResult = await this.prepareSignParams( - method, - 'v5auth', - params, - isPublicApi, - ); - - const headers: AxiosRequestConfig['headers'] = { - 'X-BAPI-SIGN-TYPE': 2, - 'X-BAPI-API-KEY': this.key, - 'X-BAPI-TIMESTAMP': signResult.timestamp, - 'X-BAPI-SIGN': signResult.sign, - 'X-BAPI-RECV-WINDOW': signResult.recvWindow, - ...options.headers, - }; - - if (method === 'GET') { - return { - ...options, - headers, - params: signResult.originalParams, - }; - } - - return { - ...options, - headers, - data: signResult.originalParams, - }; - } - const signResult = await this.prepareSignParams( method, - 'v2auth', + 'v5auth', params, isPublicApi, ); - if (method === 'GET' || this.isSpotV1Client()) { + const headers: AxiosRequestConfig['headers'] = { + 'X-BAPI-SIGN-TYPE': 2, + 'X-BAPI-API-KEY': this.key, + 'X-BAPI-TIMESTAMP': signResult.timestamp, + 'X-BAPI-SIGN': signResult.sign, + 'X-BAPI-RECV-WINDOW': signResult.recvWindow, + ...options.headers, + }; + + if (method === 'GET') { return { ...options, - params: signResult.paramsWithSign, + headers, + params: signResult.originalParams, }; } return { ...options, - data: signResult.paramsWithSign, + headers, + data: signResult.originalParams, }; } @@ -476,11 +450,7 @@ export default abstract class BaseRestClient { // Optional, set to 5000 by default. Increase if timestamp/recv_window errors are seen. if (recvWindow) { - if (this.isSpotV1Client()) { - res.originalParams.recvWindow = recvWindow; - } else { - res.originalParams.recv_window = recvWindow; - } + res.originalParams.recv_window = recvWindow; } const sortProperties = true; const encodeValues = false; diff --git a/src/util/requestUtils.ts b/src/util/requestUtils.ts index 492f7a5..751482b 100644 --- a/src/util/requestUtils.ts +++ b/src/util/requestUtils.ts @@ -207,39 +207,13 @@ export function isTopicSubscriptionConfirmation( return true; } -export function isWSAPIResponse( - msg: unknown, -): msg is Omit { - if (typeof msg !== 'object' || !msg) { - return false; - } - - if (typeof msg['op'] !== 'string') { - return false; - } - - return (WS_API_Operations as string[]).includes(msg['op']); -} - -export function isTopicSubscriptionSuccess( - msg: unknown, -): msg is WebsocketSucceededTopicSubscriptionConfirmationEvent { - if (!isTopicSubscriptionConfirmation(msg)) return false; - return msg.success === true; -} - export const APIID = 'bybitapinode'; /** * Used to switch how authentication/requests work under the hood (primarily for SPOT since it's different there) */ export const REST_CLIENT_TYPE_ENUM = { - accountAsset: 'accountAsset', - inverse: 'inverse', - inverseFutures: 'inverseFutures', - linear: 'linear', - spot: 'spot', - v3: 'v3', + v5: 'v5', } as const; export type RestClientType = diff --git a/src/util/typeGuards.ts b/src/util/typeGuards.ts index b391930..f4bcc67 100644 --- a/src/util/typeGuards.ts +++ b/src/util/typeGuards.ts @@ -2,12 +2,15 @@ * Use type guards to narrow down types with minimal efforts. */ +import { WebsocketSucceededTopicSubscriptionConfirmationEvent } from '../types'; +import { WSAPIResponse, WS_API_Operations } from '../types/websockets/ws-api'; import { WSAccountOrderEventV5, WSExecutionEventV5, WSOrderbookEventV5, WSPositionEventV5, } from '../types/websockets/ws-events'; +import { isTopicSubscriptionConfirmation } from './requestUtils'; /** * Type guard to detect a V5 orderbook event (delta & snapshots) @@ -92,3 +95,28 @@ export function isWsExecutionEventV5( return event['topic'] === 'execution'; } + +export function neverGuard(x: never, msg: string): Error { + return new Error(`Unhandled value exception "${x}", ${msg}`); +} + +export function isWSAPIResponse( + msg: unknown, +): msg is Omit { + if (typeof msg !== 'object' || !msg) { + return false; + } + + if (typeof msg['op'] !== 'string') { + return false; + } + + return (WS_API_Operations as string[]).includes(msg['op']); +} + +export function isTopicSubscriptionSuccess( + msg: unknown, +): msg is WebsocketSucceededTopicSubscriptionConfirmationEvent { + if (!isTopicSubscriptionConfirmation(msg)) return false; + return msg.success === true; +} diff --git a/src/util/webCryptoAPI.ts b/src/util/webCryptoAPI.ts index 2aea730..2743aac 100644 --- a/src/util/webCryptoAPI.ts +++ b/src/util/webCryptoAPI.ts @@ -1,4 +1,4 @@ -import { neverGuard } from './websockets'; +import { neverGuard } from './typeGuards'; function bufferToB64(buffer: ArrayBuffer): string { let binary = ''; diff --git a/src/util/websockets/websocket-util.ts b/src/util/websockets/websocket-util.ts index 883d14c..51c4b20 100644 --- a/src/util/websockets/websocket-util.ts +++ b/src/util/websockets/websocket-util.ts @@ -9,6 +9,7 @@ import { import { DefaultLogger } from '../logger'; import { WSAPIRequest } from '../../types/websockets/ws-api'; +import { neverGuard } from '../typeGuards'; export const WS_LOGGER_CATEGORY = { category: 'bybit-ws' }; @@ -301,10 +302,6 @@ export const WS_ERROR_ENUM = { USDC_OPTION_AUTH_FAILED: '3303006', }; -export function neverGuard(x: never, msg: string): Error { - return new Error(`Unhandled value exception "${x}", ${msg}`); -} - /** * #305: ws.terminate() is undefined in browsers. * This only works in node.js, not in browsers. From a4945d1cafe5ec8ca6f613ec7dc1a57621e3c9e4 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Fri, 24 Jan 2025 15:35:11 +0000 Subject: [PATCH 30/60] chore(): updates for pre-V5 deprecations --- README.md | 74 ++------------------------------------ src/util/BaseRestClient.ts | 38 +------------------- src/util/requestUtils.ts | 24 ------------- src/util/typeGuards.ts | 25 +++++++++++-- 4 files changed, 27 insertions(+), 134 deletions(-) diff --git a/README.md b/README.md index 598433c..52c7927 100644 --- a/README.md +++ b/README.md @@ -57,15 +57,14 @@ Node.js, JavaScript & TypeScript SDK for the Bybit REST APIs and WebSockets: ## Structure & Usage - [Structure](#structure) - [API Clients](#api-clients) -- [Usage](#usage) +- [REST API USAGE](#rest-api-usage) ## WebSocket Integration - [WebSockets](#websockets) - [WebSocket Subscriptions - Consuming Events](#websocket-subscriptions---consuming-events) - [Websocket API - Sending Orders via WebSockets](#websocket-api---sending-orders-via-websockets) - [Specifying Other Markets](#specifying-other-markets) -- [Load Balancing](#balancing-load-across-multiple-connections) -- [Older Websocket APIs](#older-websocket-apis) +- [Consumer Load Balancing](#balancing-load-across-multiple-connections) ## Additional Features - [Logging](#logging) @@ -154,7 +153,7 @@ Here are the available REST clients and the corresponding API groups described i | [WebsocketClient](src/websocket-client.ts) | All WebSocket Events (Public & Private for all API categories) | -### Usage +## REST API Usage Create API credentials on Bybit's website: @@ -257,38 +256,6 @@ client.getOrderbook({ category: 'linear', symbol: 'BTCUSDT' }) --- -### Deprecated/Obsolete REST APIs - -The following API clients are for previous generation REST APIs and will be removed in the next major release. Some have already stopped working (because bybit stopped supporting them). You should use the V5 APIs for all new development. - -
- Click me to read more - -Each generation is labelled with the version number (e.g. v1/v2/v3/v5). New projects & developments should use the newest available API generation (e.g. use the V5 APIs instead of V3). - - -| Class | Description | -| :--------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------: | -| [ **Derivatives v3** ] | The Derivatives v3 APIs (successor to the Futures V2 APIs) | -| [UnifiedMarginClient](src/unified-margin-client.ts) |[Derivatives (v3) Unified Margin APIs](https://bybit-exchange.github.io/docs/derivatives/unified/place-order) | -| [ContractClient](src/contract-client.ts) | [Derivatives (v3) Contract APIs](https://bybit-exchange.github.io/docs/derivatives/contract/place-order). | -| [ **Futures v2** ] | The Futures v2 APIs | -| [~~InverseClient~~](src/inverse-client.ts) | [Inverse Perpetual Futures (v2) APIs](https://bybit-exchange.github.io/docs/futuresV2/inverse/) | -| [~~LinearClient~~](src/linear-client.ts) | [USDT Perpetual Futures (v2) APIs](https://bybit-exchange.github.io/docs/futuresV2/linear/#t-introduction) | -| [~~InverseFuturesClient~~](src/inverse-futures-client.ts) | [Inverse Futures (v2) APIs](https://bybit-exchange.github.io/docs/futuresV2/inverse_futures/#t-introduction) | -| [ **Spot** ] | The spot APIs | -| [SpotClientV3](src/spot-client-v3.ts) | [Spot Market (v3) APIs](https://bybit-exchange.github.io/docs/spot/public/instrument) | -| [~~SpotClient~~](src/spot-client.ts) (deprecated, SpotClientV3 recommended) | [Spot Market (v1) APIs](https://bybit-exchange.github.io/docs/spot/v1/#t-introduction) | -| [ **USDC Contract** ] | The USDC Contract APIs | -| [USDCPerpetualClient](src/usdc-perpetual-client.ts) | [USDC Perpetual APIs](https://bybit-exchange.github.io/docs/usdc/option/?console#t-querydeliverylog) | -| [USDCOptionClient](src/usdc-option-client.ts) | [USDC Option APIs](https://bybit-exchange.github.io/docs/usdc/option/#t-introduction) | -| [~~AccountAssetClient~~](src/account-asset-client.ts) | [Account Asset V1 APIs](https://bybit-exchange.github.io/docs/account_asset/v1/#t-introduction) | -| [ **Other** ] | Other standalone API groups | -| [CopyTradingClient](src/copy-trading-client.ts) | [Copy Trading APIs](https://bybit-exchange.github.io/docs/category/copy-trade) | -| [AccountAssetClientV3](src/account-asset-client-v3.ts) | [Account Asset V3 APIs](https://bybit-exchange.github.io/docs/account-asset/internal-transfer) | - -
- ## WebSockets The WebsocketClient will automatically use the latest V5 WebSocket endpoints by default. To use a different endpoint, use the `market` parameter. Except for the WebSocket API - this can be accessed without any special configuration. @@ -317,15 +284,6 @@ const wsConfig = { The following parameters are optional: */ - /** - * The API group this client should connect to. The V5 market is currently used by default. - * - * For the V3 APIs use `v3` as the market (spot/unified margin/usdc/account - * asset/copy trading). - * Note that older API groups are deprecated and may stop working soon. - */ - // market: 'v5', - /** * Set to `true` to connect to Bybit's testnet environment. * - If demo trading, `testnet` should be set to false! @@ -553,32 +511,6 @@ Important: do not subscribe to the same topics on both clients or you will recei --- -### Older Websocket APIs - -The following API groups are still available in the WebsocketClient but are deprecated and may no longer work. They will be removed in the next major release: - -
- Click me to see the table - -| API Category | Market | Description | -| :------------------------------: | :-------------------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ~~Unified Margin - Options~~ | `market: 'unifiedOption'` | The [derivatives v3](https://bybit-exchange.github.io/docs/derivativesV3/unified_margin/#t-websocket) category for unified margin. Note: public topics only support options topics. If you need USDC/USDT perps, use `unifiedPerp` instead. | -| ~~Unified Margin - Perps~~ | `market: 'unifiedPerp'` | The [derivatives v3](https://bybit-exchange.github.io/docs/derivativesV3/unified_margin/#t-websocket) category for unified margin. Note: public topics only support USDT/USDC perpetual topics - use `unifiedOption` if you need public options topics. | -| ~~Futures v2 - Inverse Perps~~ | `market: 'inverse'` | The [inverse v2 perps](https://bybit-exchange.github.io/docs/futuresV2/inverse/#t-websocket) category. | -| ~~Futures v2 - USDT Perps~~ | `market: 'linear'` | The [USDT/linear v2 perps](https://bybit-exchange.github.io/docs/futuresV2/linear/#t-websocket) category. | -| ~~Futures v2 - Inverse Futures~~ | `market: 'inverse'` | The [inverse futures v2](https://bybit-exchange.github.io/docs/futuresV2/inverse_futures/#t-websocket) category uses the same market as inverse perps. | -| ~~Spot v3~~ | `market: 'spotv3'` | The [spot v3](https://bybit-exchange.github.io/docs/spot/v3/#t-websocket) category. | -| ~~Spot v1~~ | `market: 'spot'` | The older [spot v1](https://bybit-exchange.github.io/docs/spot/v1/#t-websocket) category. Use the `spotv3` market if possible, as the v1 category does not have automatic re-subscribe if reconnected. | -| ~~Copy Trading~~ | `market: 'linear'` | The [copy trading](https://bybit-exchange.github.io/docs/copy_trading/#t-websocket) category. Use the linear market to listen to all copy trading topics. | -| ~~USDC Perps~~ | `market: 'usdcPerp` | The [USDC perps](https://bybit-exchange.github.io/docs/usdc/perpetual/#t-websocket) category. | -| ~~USDC Options~~ | `market: 'usdcOption'` | The [USDC options](https://bybit-exchange.github.io/docs/usdc/option/#t-websocket) category. | -| ~~Contract v3 USDT~~ | `market: 'contractUSDT'` | The [Contract V3](https://bybit-exchange.github.io/docs/derivativesV3/contract/#t-websocket) category (USDT perps) | -| ~~Contract v3 Inverse~~ | `market: 'contractInverse'` | The [Contract V3](https://bybit-exchange.github.io/docs/derivativesV3/contract/#t-websocket) category (inverse perps) | - -
{ @@ -72,7 +70,7 @@ interface UnsignedRequest { recvWindow?: number; } -type SignMethod = 'v2auth' | 'v5auth'; +type SignMethod = 'v5auth'; export default abstract class BaseRestClient { private timeOffset: number | null = null; @@ -443,40 +441,6 @@ export default abstract class BaseRestClient { return res; } - // spot/v2 derivatives - if (signMethod === 'v2auth') { - res.originalParams.api_key = key; - res.originalParams.timestamp = timestamp; - - // Optional, set to 5000 by default. Increase if timestamp/recv_window errors are seen. - if (recvWindow) { - res.originalParams.recv_window = recvWindow; - } - const sortProperties = true; - const encodeValues = false; - - res.serializedParams = serializeParams( - res.originalParams, - strictParamValidation, - sortProperties, - encodeValues, - ); - res.sign = await this.signMessage( - res.serializedParams, - this.secret, - 'hex', - 'SHA-256', - ); - - // @ts-ignore - res.paramsWithSign = { - ...res.originalParams, - sign: res.sign, - }; - - return res; - } - return res; } diff --git a/src/util/requestUtils.ts b/src/util/requestUtils.ts index 751482b..b067c6d 100644 --- a/src/util/requestUtils.ts +++ b/src/util/requestUtils.ts @@ -1,10 +1,5 @@ import { AxiosResponse } from 'axios'; import { APIRateLimit } from '../types'; -import { - WebsocketSucceededTopicSubscriptionConfirmationEvent, - WebsocketTopicSubscriptionConfirmationEvent, -} from '../types/websockets/ws-confirmations'; -import { WSAPIResponse, WS_API_Operations } from '../types/websockets/ws-api'; export interface RestClientOptions { /** Your API key */ @@ -188,25 +183,6 @@ export function isWsPong(msg: any): boolean { ); } -export function isTopicSubscriptionConfirmation( - msg: unknown, -): msg is WebsocketTopicSubscriptionConfirmationEvent { - if (typeof msg !== 'object') { - return false; - } - if (!msg) { - return false; - } - if (typeof msg['op'] !== 'string') { - return false; - } - if (msg['op'] !== 'subscribe') { - return false; - } - - return true; -} - export const APIID = 'bybitapinode'; /** diff --git a/src/util/typeGuards.ts b/src/util/typeGuards.ts index f4bcc67..f721f99 100644 --- a/src/util/typeGuards.ts +++ b/src/util/typeGuards.ts @@ -2,7 +2,10 @@ * Use type guards to narrow down types with minimal efforts. */ -import { WebsocketSucceededTopicSubscriptionConfirmationEvent } from '../types'; +import { + WebsocketSucceededTopicSubscriptionConfirmationEvent, + WebsocketTopicSubscriptionConfirmationEvent, +} from '../types'; import { WSAPIResponse, WS_API_Operations } from '../types/websockets/ws-api'; import { WSAccountOrderEventV5, @@ -10,7 +13,6 @@ import { WSOrderbookEventV5, WSPositionEventV5, } from '../types/websockets/ws-events'; -import { isTopicSubscriptionConfirmation } from './requestUtils'; /** * Type guard to detect a V5 orderbook event (delta & snapshots) @@ -120,3 +122,22 @@ export function isTopicSubscriptionSuccess( if (!isTopicSubscriptionConfirmation(msg)) return false; return msg.success === true; } + +export function isTopicSubscriptionConfirmation( + msg: unknown, +): msg is WebsocketTopicSubscriptionConfirmationEvent { + if (typeof msg !== 'object') { + return false; + } + if (!msg) { + return false; + } + if (typeof msg['op'] !== 'string') { + return false; + } + if (msg['op'] !== 'subscribe') { + return false; + } + + return true; +} From 0d208224833b495eb981e133bc4ddeb222cae717 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Fri, 24 Jan 2025 15:41:34 +0000 Subject: [PATCH 31/60] chore(): update default logger examples --- README.md | 9 --------- examples/demo-trading.ts | 4 ++-- examples/fasterHmacSign.ts | 4 ++-- examples/ws-private-v5.ts | 4 ++-- examples/ws-public-v5.ts | 10 ++-------- test/v5/public.ws.test.ts | 4 +--- 6 files changed, 9 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 52c7927..8f94d49 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,6 @@ Node.js, JavaScript & TypeScript SDK for the Bybit REST APIs and WebSockets: - [WebSockets](#websockets) - [WebSocket Subscriptions - Consuming Events](#websocket-subscriptions---consuming-events) - [Websocket API - Sending Orders via WebSockets](#websocket-api---sending-orders-via-websockets) -- [Specifying Other Markets](#specifying-other-markets) - [Consumer Load Balancing](#balancing-load-across-multiple-connections) ## Additional Features @@ -485,14 +484,6 @@ See the [examples/ws-api-promises.ts](./examples/ws-api-promises.ts) example for --- -### Specifying other markets - -The WebsocketClient can be configured to a specific API group using the market parameter. These are the currently available API groups: -| API Category | Market | Description | -|:----------------------------: |:-------------------: |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| V5 Subscriptions | `market: 'v5'` | The [v5](https://bybit-exchange.github.io/docs/v5/ws/connect) websocket topics for all categories under one market. Use the subscribeV5 method when subscribing to v5 topics. | - - ### Balancing load across multiple connections The WebsocketClient will automatically prepare one connection per API group, for all topics in that API group. Any topics that you subscribe to on that WebSocket client will automatically be added to the same connection. diff --git a/examples/demo-trading.ts b/examples/demo-trading.ts index 36a0aaa..190e41c 100644 --- a/examples/demo-trading.ts +++ b/examples/demo-trading.ts @@ -29,14 +29,14 @@ const restClient = new RestClientV5({ // Optional, uncomment the "silly" override to log a lot more info about what the WS client is doing const customLogger = { ...DefaultLogger, - // silly: (...params) => console.log('trace', ...params), + // trace: (...params) => console.log('trace', ...params), }; const wsClient = new WebsocketClient( { key: key, secret: secret, - market: 'v5', + /** * Set this to true to enable demo trading for the private account data WS * Topics: order,execution,position,wallet,greeks diff --git a/examples/fasterHmacSign.ts b/examples/fasterHmacSign.ts index 818087d..d9dcca2 100644 --- a/examples/fasterHmacSign.ts +++ b/examples/fasterHmacSign.ts @@ -46,10 +46,10 @@ const restClient = new RestClientV5({ }, }); -// Optional, uncomment the "silly" override to log a lot more info about what the WS client is doing +// Optional, uncomment the "trace" override to log a lot more info about what the WS client is doing const customLogger = { ...DefaultLogger, - // silly: (...params) => console.log('trace', ...params), + // trace: (...params) => console.log('trace', ...params), }; const wsClient = new WebsocketClient( diff --git a/examples/ws-private-v5.ts b/examples/ws-private-v5.ts index e4bb3f1..f9a4f1a 100644 --- a/examples/ws-private-v5.ts +++ b/examples/ws-private-v5.ts @@ -4,10 +4,10 @@ import { DefaultLogger, WS_KEY_MAP, WebsocketClient } from '../src'; // or // import { DefaultLogger, WS_KEY_MAP, WebsocketClient } from 'bybit-api'; -// Create & inject a custom logger to disable the silly logging level (empty function) +// Create & inject a custom logger to enable the trace logging level (empty function) const logger = { ...DefaultLogger, - silly: () => {}, + // trace: (...params) => console.log('trace', ...params), }; const key = process.env.API_KEY; diff --git a/examples/ws-public-v5.ts b/examples/ws-public-v5.ts index 6f3e7ca..118181d 100644 --- a/examples/ws-public-v5.ts +++ b/examples/ws-public-v5.ts @@ -5,7 +5,7 @@ import { DefaultLogger, WS_KEY_MAP, WebsocketClient } from '../src'; const logger = { ...DefaultLogger, - silly: (...params) => console.log('silly', ...params), + trace: (...params) => console.log('trace', ...params), }; /** @@ -17,13 +17,7 @@ const logger = { * - Heartbeats/ping/pong/reconnects are all handled automatically. * If a connection drops, the client will clean it up, respawn a fresh connection and resubscribe for you. */ -const wsClient = new WebsocketClient( - { - // Previously required, this parameter is now optional: - // market: 'v5', - }, - logger, -); +const wsClient = new WebsocketClient({}, logger); wsClient.on('update', (data) => { console.log('raw message received ', JSON.stringify(data)); diff --git a/test/v5/public.ws.test.ts b/test/v5/public.ws.test.ts index d86205d..f389411 100644 --- a/test/v5/public.ws.test.ts +++ b/test/v5/public.ws.test.ts @@ -1,9 +1,7 @@ import { WebsocketClient } from '../../src'; describe.skip('Public V5 Websocket client', () => { - const api = new WebsocketClient({ - market: 'v5', - }); + const api = new WebsocketClient({}); const linearSymbol = 'BTCUSDT'; const linearCategory = 'linear'; From 86fc739eca0c692ff7efc95e9f0b586917541952 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Fri, 24 Jan 2025 15:46:20 +0000 Subject: [PATCH 32/60] feat(v4.0.0-beta.0): bump version to public beta. WS API integration. Remove deprecated V1-V3 support. WS upgrades. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index db075f5..d40e608 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bybit-api", - "version": "3.10.29", + "version": "4.0.0-beta.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "bybit-api", - "version": "3.10.29", + "version": "4.0.0-beta.0", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/package.json b/package.json index 6318778..ee4385c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bybit-api", - "version": "3.10.29", + "version": "4.0.0-beta.0", "description": "Complete & robust Node.js SDK for Bybit's REST APIs and WebSockets, with TypeScript & strong end to end tests.", "main": "lib/index.js", "types": "lib/index.d.ts", From 8a692140e02e5002e0a14ea94144653ace0dcc7d Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Fri, 24 Jan 2025 16:20:12 +0000 Subject: [PATCH 33/60] fix(4.0.0-beta.1): fix backwards compatibility with req_id usage for ws subscribe/unsubscribe --- package-lock.json | 4 ++-- package.json | 2 +- src/util/BaseRestClient.ts | 1 - src/websocket-client.ts | 12 ++++++++++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index d40e608..34c5f81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bybit-api", - "version": "4.0.0-beta.0", + "version": "4.0.0-beta.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "bybit-api", - "version": "4.0.0-beta.0", + "version": "4.0.0-beta.1", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/package.json b/package.json index ee4385c..83b6a1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bybit-api", - "version": "4.0.0-beta.0", + "version": "4.0.0-beta.1", "description": "Complete & robust Node.js SDK for Bybit's REST APIs and WebSockets, with TypeScript & strong end to end tests.", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/src/util/BaseRestClient.ts b/src/util/BaseRestClient.ts index 8a6dc0f..fb34e64 100644 --- a/src/util/BaseRestClient.ts +++ b/src/util/BaseRestClient.ts @@ -4,7 +4,6 @@ import https from 'https'; import { APIID, - REST_CLIENT_TYPE_ENUM, RestClientOptions, RestClientType, getRestBaseUrl, diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 3ed2ccf..d26df78 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -550,10 +550,18 @@ export class WebsocketClient extends BaseWebsocketClient< switch (market) { case 'all': { + const topics = requests.map((r) => r.topic); + + // Previously used to track topics in a request. Keeping this for subscribe/unsubscribe requests, no need for incremental values + const req_id = + ['subscribe', 'unsubscribe'].includes(operation) && topics.length + ? topics.join('_') + : this.getNewRequestId(); + const wsEvent: WsRequestOperationBybit = { - req_id: this.getNewRequestId(), + req_id: req_id, op: operation, - args: requests.map((r) => r.topic), + args: topics, }; const midflightWsEvent: MidflightWsRequestEvent< From 1d358e0896b97e03e2976589b0917c2226f745bc Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Fri, 24 Jan 2025 16:26:24 +0000 Subject: [PATCH 34/60] fix(v4.0.0-beta.2): separator for req_id topic list --- package-lock.json | 4 ++-- package.json | 2 +- src/websocket-client.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 34c5f81..e9c6d34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bybit-api", - "version": "4.0.0-beta.1", + "version": "4.0.0-beta.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "bybit-api", - "version": "4.0.0-beta.1", + "version": "4.0.0-beta.2", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/package.json b/package.json index 83b6a1f..53cf797 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bybit-api", - "version": "4.0.0-beta.1", + "version": "4.0.0-beta.2", "description": "Complete & robust Node.js SDK for Bybit's REST APIs and WebSockets, with TypeScript & strong end to end tests.", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/src/websocket-client.ts b/src/websocket-client.ts index d26df78..c19de74 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -555,7 +555,7 @@ export class WebsocketClient extends BaseWebsocketClient< // Previously used to track topics in a request. Keeping this for subscribe/unsubscribe requests, no need for incremental values const req_id = ['subscribe', 'unsubscribe'].includes(operation) && topics.length - ? topics.join('_') + ? topics.join(',') : this.getNewRequestId(); const wsEvent: WsRequestOperationBybit = { From d0511426b1b1b416f86c39c03f4ba96348533c22 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Sat, 1 Feb 2025 15:27:41 +0000 Subject: [PATCH 35/60] chore(): misc cleaning & note around exception emitter, fix(#411): only resolve demo trading url for private topics --- src/util/BaseWSClient.ts | 80 ++++++++++++++++++++------- src/util/websockets/websocket-util.ts | 12 ++-- 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts index 43cd23e..59657da 100644 --- a/src/util/BaseWSClient.ts +++ b/src/util/BaseWSClient.ts @@ -38,8 +38,17 @@ interface WSClientEventMap { ) => void; /** Received data for topic */ update: (response: any & { wsKey: WsKey }) => void; - /** Exception from ws client OR custom listeners (e.g. if you throw inside your event handler) */ + /** + * Exception from ws client OR custom listeners (e.g. if you throw inside your event handler) + * @deprecated Use 'exception' instead. The 'error' event had the unintended consequence of throwing an unhandled promise rejection. + */ error: (response: any & { wsKey: WsKey; isWSAPIResponse?: boolean }) => void; + /** + * Exception from ws client OR custom listeners (e.g. if you throw inside your event handler) + */ + exception: ( + response: any & { wsKey: WsKey; isWSAPIResponse?: boolean }, + ) => void; /** Confirmation that a connection successfully authenticated */ authenticated: (event: { wsKey: WsKey; @@ -71,14 +80,6 @@ export interface BaseWebsocketClient< ): boolean; } -// interface TopicsPendingSubscriptions { -// wsKey: string; -// failedTopicsSubscriptions: Set; -// pendingTopicsSubscriptions: Set; -// resolver: TopicsPendingSubscriptionsResolver; -// rejector: TopicsPendingSubscriptionsRejector; -// } - /** * A midflight WS request event (e.g. subscribe to these topics). * @@ -168,8 +169,10 @@ export abstract class BaseWebsocketClient< }; // add default error handling so this doesn't crash node (if the user didn't set a handler) - // eslint-disable-next-line @typescript-eslint/no-empty-function - this.on('error', () => {}); + // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, no-unused-vars + this.on('error', (e) => { + // console.log('basewserr: ', e); + }); } /** @@ -339,10 +342,18 @@ export abstract class BaseWebsocketClient< pendingSubscriptionRequest.requestData, ); } else { - pendingSubscriptionRequest.rejector( - pendingSubscriptionRequest.requestData, + this.logger.trace( + `updatePendingTopicSubscriptionStatus.reject(${wsKey}, ${requestKey}, ${msg}, ${isTopicSubscriptionSuccessEvent}): `, msg, ); + try { + pendingSubscriptionRequest.rejector( + pendingSubscriptionRequest.requestData, + msg, + ); + } catch (e) { + console.error('Exception rejecting promise: ', e); + } } this.removeTopicPendingSubscription(wsKey, requestKey); @@ -614,6 +625,8 @@ export abstract class BaseWebsocketClient< break; } + this.logger.error(`parseWsError(${context}, ${error}, ${wsKey}) `, error); + this.emit('response', { ...error, wsKey }); this.emit('error', { ...error, wsKey }); } @@ -1008,12 +1021,28 @@ export abstract class BaseWebsocketClient< ); // Request sub to public topics, if any - this.requestSubscribeTopics(wsKey, publicReqs); + try { + await this.requestSubscribeTopics(wsKey, publicReqs); + } catch (e) { + this.logger.error( + `onWsOpen(): exception in public requestSubscribeTopics(${wsKey}): `, + publicReqs, + e, + ); + } // Request sub to private topics, if auth on connect isn't needed // Else, this is automatic after authentication is successfully confirmed if (!this.options.authPrivateConnectionsOnConnect) { - this.requestSubscribeTopics(wsKey, privateReqs); + try { + this.requestSubscribeTopics(wsKey, privateReqs); + } catch (e) { + this.logger.error( + `onWsOpen(): exception in private requestSubscribeTopics(${wsKey}: `, + privateReqs, + e, + ); + } } // Some websockets require an auth packet to be sent after opening the connection @@ -1114,10 +1143,6 @@ export abstract class BaseWebsocketClient< } for (const emittable of emittableEvents) { - // if (emittable.event?.op) { - // console.log('emittable: ', emittable); - // } - if (this.isWsPong(emittable)) { this.logger.trace('Received pong2', { ...WS_LOGGER_CATEGORY, @@ -1143,7 +1168,22 @@ export abstract class BaseWebsocketClient< continue; } - this.emit(emittable.eventType, emittableFinalEvent); + // this.logger.trace( + // `onWsMessage().emit(${emittable.eventType})`, + // emittableFinalEvent, + // ); + try { + this.emit(emittable.eventType, emittableFinalEvent); + } catch (e) { + this.logger.error( + `Exception in onWsMessage().emit(${emittable.eventType}) handler:`, + e, + ); + } + // this.logger.trace( + // `onWsMessage().emit(${emittable.eventType}).done()`, + // emittableFinalEvent, + // ); } return; diff --git a/src/util/websockets/websocket-util.ts b/src/util/websockets/websocket-util.ts index 51c4b20..b979aa4 100644 --- a/src/util/websockets/websocket-util.ts +++ b/src/util/websockets/websocket-util.ts @@ -241,19 +241,23 @@ export function getWsUrl( } // https://bybit-exchange.github.io/docs/v5/demo - const isDemoTrading = wsClientOptions.demoTrading; - if (isDemoTrading) { - return 'wss://stream-demo.bybit.com/v5/private'; - } + const demoTradingPrivateEndpoint = 'wss://stream-demo.bybit.com/v5/private'; + const isDemoTrading = wsClientOptions.demoTrading; const isTestnet = wsClientOptions.testnet; const networkKey = isTestnet ? 'testnet' : 'livenet'; switch (wsKey) { case WS_KEY_MAP.v5Private: { + if (isDemoTrading) { + return demoTradingPrivateEndpoint; + } return WS_BASE_URL_MAP.v5.private[networkKey]; } case WS_KEY_MAP.v5PrivateTrade: { + if (isDemoTrading) { + return demoTradingPrivateEndpoint; + } return WS_BASE_URL_MAP[wsKey].private[networkKey]; } case WS_KEY_MAP.v5SpotPublic: { From 57b1a72b7f7777c660f211bd30f290a0180022d5 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Thu, 6 Feb 2025 12:08:51 +0000 Subject: [PATCH 36/60] feat(v4.0.0-beta.5): BREAKING CHANGE: rename "error" event to "exception" to avoid unhandled exceptions --- examples/demo-trading.ts | 2 +- examples/fasterHmacSign.ts | 2 +- examples/ws-api-promises.ts | 4 ++-- examples/ws-public-v5.ts | 15 ++++++--------- package-lock.json | 4 ++-- package.json | 2 +- src/util/BaseWSClient.ts | 30 +++++++++++++----------------- src/websocket-client.ts | 8 ++++---- test/v5/public.ws.test.ts | 10 +++++++--- test/ws.util.ts | 10 +++++----- 10 files changed, 42 insertions(+), 45 deletions(-) diff --git a/examples/demo-trading.ts b/examples/demo-trading.ts index 190e41c..b4002a0 100644 --- a/examples/demo-trading.ts +++ b/examples/demo-trading.ts @@ -91,7 +91,7 @@ function setWsClientEventListeners( websocketClient.on('reconnected', (data) => { console.log(new Date(), accountRef, 'ws has reconnected ', data?.wsKey); }); - websocketClient.on('error', (data) => { + websocketClient.on('exception', (data) => { console.error(new Date(), accountRef, 'ws exception: ', data); }); }); diff --git a/examples/fasterHmacSign.ts b/examples/fasterHmacSign.ts index d9dcca2..99b3f4f 100644 --- a/examples/fasterHmacSign.ts +++ b/examples/fasterHmacSign.ts @@ -117,7 +117,7 @@ function setWsClientEventListeners( websocketClient.on('reconnected', (data) => { console.log(new Date(), accountRef, 'ws has reconnected ', data?.wsKey); }); - websocketClient.on('error', (data) => { + websocketClient.on('exception', (data) => { console.error(new Date(), accountRef, 'ws exception: ', data); }); }); diff --git a/examples/ws-api-promises.ts b/examples/ws-api-promises.ts index 5be77d9..4ccf41e 100644 --- a/examples/ws-api-promises.ts +++ b/examples/ws-api-promises.ts @@ -40,8 +40,8 @@ wsClient.on('reconnected', (data) => { wsClient.on('authenticated', (data) => { console.log('ws has authenticated ', data?.wsKey); }); -wsClient.on('error', (data) => { - console.error('ws error: ', data); +wsClient.on('exception', (data) => { + console.error('ws exception: ', data); }); async function main() { diff --git a/examples/ws-public-v5.ts b/examples/ws-public-v5.ts index 3fe50a4..1feea00 100644 --- a/examples/ws-public-v5.ts +++ b/examples/ws-public-v5.ts @@ -17,12 +17,8 @@ const logger = { * - Heartbeats/ping/pong/reconnects are all handled automatically. * If a connection drops, the client will clean it up, respawn a fresh connection and resubscribe for you. */ -const wsClient = new WebsocketClient( - { - // demoTrading: true, - }, - logger, -); + +const wsClient = new WebsocketClient(); wsClient.on('update', (data) => { console.log('raw message received ', JSON.stringify(data)); @@ -40,9 +36,10 @@ wsClient.on('reconnect', ({ wsKey }) => { wsClient.on('reconnected', (data) => { console.log('ws has reconnected ', data?.wsKey); }); -// wsClient.on('error', (data) => { -// console.error('ws exception: ', data); -// }); + +wsClient.on('exception', (data) => { + console.error('ws exception: ', data); +}); /** * For public V5 topics, use the subscribeV5 method and include the API category this topic is for. diff --git a/package-lock.json b/package-lock.json index c3edbf4..35b92ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bybit-api", - "version": "4.0.0-beta.4", + "version": "4.0.0-beta.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "bybit-api", - "version": "4.0.0-beta.4", + "version": "4.0.0-beta.5", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/package.json b/package.json index 8018b86..ae5ab4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bybit-api", - "version": "4.0.0-beta.4", + "version": "4.0.0-beta.5", "description": "Complete & robust Node.js SDK for Bybit's REST APIs and WebSockets, with TypeScript & strong end to end tests.", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts index 59657da..f344907 100644 --- a/src/util/BaseWSClient.ts +++ b/src/util/BaseWSClient.ts @@ -23,6 +23,8 @@ import { } from './websockets'; import { WsOperation } from '../types/websockets/ws-api'; +type UseTheExceptionEventInstead = never; + interface WSClientEventMap { /** Connection opened. If this connection was previously opened and reconnected, expect the reconnected event instead */ open: (evt: { wsKey: WsKey; event: any }) => void; @@ -39,10 +41,10 @@ interface WSClientEventMap { /** Received data for topic */ update: (response: any & { wsKey: WsKey }) => void; /** - * Exception from ws client OR custom listeners (e.g. if you throw inside your event handler) - * @deprecated Use 'exception' instead. The 'error' event had the unintended consequence of throwing an unhandled promise rejection. + * See for more information: https://github.com/tiagosiebler/bybit-api/issues/413 + * @deprecated Use the 'exception' event instead. The 'error' event had the unintended consequence of throwing an unhandled promise rejection. */ - error: (response: any & { wsKey: WsKey; isWSAPIResponse?: boolean }) => void; + error: UseTheExceptionEventInstead; /** * Exception from ws client OR custom listeners (e.g. if you throw inside your event handler) */ @@ -57,12 +59,6 @@ interface WSClientEventMap { }) => void; } -export interface EmittableEvent { - eventType: 'response' | 'update' | 'error' | 'authenticated'; - event: TEvent; - isWSAPIResponse?: boolean; -} - // Type safety for on and emit handlers: https://stackoverflow.com/a/61609010/880837 export interface BaseWebsocketClient< TWSKey extends string, @@ -80,6 +76,12 @@ export interface BaseWebsocketClient< ): boolean; } +export interface EmittableEvent { + eventType: 'response' | 'update' | 'exception' | 'authenticated'; + event: TEvent; + isWSAPIResponse?: boolean; +} + /** * A midflight WS request event (e.g. subscribe to these topics). * @@ -167,12 +169,6 @@ export abstract class BaseWebsocketClient< authPrivateRequests: false, ...options, }; - - // add default error handling so this doesn't crash node (if the user didn't set a handler) - // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, no-unused-vars - this.on('error', (e) => { - // console.log('basewserr: ', e); - }); } /** @@ -593,7 +589,7 @@ export abstract class BaseWebsocketClient< if (!error.message) { this.logger.error(`${context} due to unexpected error: `, error); this.emit('response', { ...error, wsKey }); - this.emit('error', { ...error, wsKey }); + this.emit('exception', { ...error, wsKey }); return; } @@ -628,7 +624,7 @@ export abstract class BaseWebsocketClient< this.logger.error(`parseWsError(${context}, ${error}, ${wsKey}) `, error); this.emit('response', { ...error, wsKey }); - this.emit('error', { ...error, wsKey }); + this.emit('exception', { ...error, wsKey }); } /** Get a signature, build the auth request and send it */ diff --git a/src/websocket-client.ts b/src/websocket-client.ts index c19de74..d87bbd0 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -136,7 +136,7 @@ export class WebsocketClient extends BaseWebsocketClient< perWsKeyTopics[derivedWsKey] = []; } - perWsKeyTopics[derivedWsKey].push(wsRequest); + perWsKeyTopics[derivedWsKey]!.push(wsRequest); } const promises: Promise[] = []; @@ -755,7 +755,7 @@ export class WebsocketClient extends BaseWebsocketClient< } results.push({ - eventType: 'error', + eventType: 'exception', event: parsed, isWSAPIResponse: true, }); @@ -804,7 +804,7 @@ export class WebsocketClient extends BaseWebsocketClient< // Failed request if (parsed.success === false) { results.push({ - eventType: 'error', + eventType: 'exception', event: parsed, }); return results; @@ -851,7 +851,7 @@ export class WebsocketClient extends BaseWebsocketClient< exception: e, eventData: event.data, }, - eventType: 'error', + eventType: 'exception', }); this.logger.error('Failed to parse event data due to exception: ', { diff --git a/test/v5/public.ws.test.ts b/test/v5/public.ws.test.ts index f389411..3d35898 100644 --- a/test/v5/public.ws.test.ts +++ b/test/v5/public.ws.test.ts @@ -9,13 +9,17 @@ describe.skip('Public V5 Websocket client', () => { describe('Topics subscription confirmation', () => { it('can subscribeV5 to LINEAR with valid topic', async () => { await expect( - api.subscribeV5(`publicTrade.${linearSymbol}`, linearCategory), - ).resolves.toBeUndefined(); + Promise.allSettled( + api.subscribeV5(`publicTrade.${linearSymbol}`, linearCategory), + ), + ).resolves.toStrictEqual([]); }); it('cannot subscribeV5 to LINEAR with valid topic', async () => { try { - await api.subscribeV5(`publicTrade.${linearSymbol}X`, linearCategory); + await Promise.allSettled( + api.subscribeV5(`publicTrade.${linearSymbol}X`, linearCategory), + ); } catch (e) { expect(e).toBeDefined(); expect(e).toMatch(`(publicTrade.${linearSymbol}X) failed to subscribe`); diff --git a/test/ws.util.ts b/test/ws.util.ts index a8c1534..db82eee 100644 --- a/test/ws.util.ts +++ b/test/ws.util.ts @@ -62,7 +62,7 @@ export function waitForSocketEvent( } wsClient.on(event, (e) => resolver(e)); - wsClient.on('error', (e) => rejector(e)); + wsClient.on('exception', (e) => rejector(e)); // if (event !== 'close') { // wsClient.on('close', (event) => { @@ -78,21 +78,21 @@ export function waitForSocketEvent( export function listenToSocketEvents(wsClient: WebsocketClient) { const retVal: Record< - 'update' | 'open' | 'response' | 'close' | 'error', + 'update' | 'open' | 'response' | 'close' | 'exception', typeof jest.fn > = { open: jest.fn(), response: jest.fn(), update: jest.fn(), close: jest.fn(), - error: jest.fn(), + exception: jest.fn(), }; wsClient.on('open', retVal.open); wsClient.on('response', retVal.response); wsClient.on('update', retVal.update); wsClient.on('close', retVal.close); - wsClient.on('error', retVal.error); + wsClient.on('exception', retVal.exception); return { ...retVal, @@ -101,7 +101,7 @@ export function listenToSocketEvents(wsClient: WebsocketClient) { wsClient.removeListener('response', retVal.response); wsClient.removeListener('update', retVal.update); wsClient.removeListener('close', retVal.close); - wsClient.removeListener('error', retVal.error); + wsClient.removeListener('exception', retVal.exception); }, }; } From 2f18edec02b3bad58887e98b8302425dbbddd121 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Thu, 6 Feb 2025 12:14:28 +0000 Subject: [PATCH 37/60] chore(): add logger injection to example --- examples/ws-public-v5.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/ws-public-v5.ts b/examples/ws-public-v5.ts index 1feea00..99d37db 100644 --- a/examples/ws-public-v5.ts +++ b/examples/ws-public-v5.ts @@ -18,7 +18,7 @@ const logger = { * If a connection drops, the client will clean it up, respawn a fresh connection and resubscribe for you. */ -const wsClient = new WebsocketClient(); +const wsClient = new WebsocketClient({}, logger); wsClient.on('update', (data) => { console.log('raw message received ', JSON.stringify(data)); From 912093245df3b1a97b61b9138214ca92da8d402f Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Mon, 17 Feb 2025 11:00:20 +0000 Subject: [PATCH 38/60] feat(): optional promisified subscribe requests --- src/types/websockets/ws-general.ts | 9 +++++++++ src/util/BaseWSClient.ts | 32 ++++++++++++++++++++++++------ src/util/websockets/WsStore.ts | 3 +++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/types/websockets/ws-general.ts b/src/types/websockets/ws-general.ts index bd4d52e..7a61f3d 100644 --- a/src/types/websockets/ws-general.ts +++ b/src/types/websockets/ws-general.ts @@ -123,6 +123,15 @@ export interface WSClientConfigurableOptions { wsUrl?: string; + /** + * Default: false. + * + * When enabled, any calls to the subscribe method will return a promise. + * Note: internally, subscription requests are sent in batches. This may not behave as expected when + * subscribing to a large number of topics, especially if you are not yet connected when subscribing. + */ + promiseSubscribeRequests?: boolean; + /** * Allows you to provide a custom "signMessage" function, e.g. to use node's much faster createHmac method * diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts index f344907..24b7f76 100644 --- a/src/util/BaseWSClient.ts +++ b/src/util/BaseWSClient.ts @@ -163,10 +163,15 @@ export abstract class BaseWebsocketClient< reconnectTimeout: 500, recvWindow: 5000, + // Calls to subscribeV5() are wrapped in a promise, allowing you to await a subscription request. + // Note: due to internal complexity, it's only recommended if you connect before subscribing. + promiseSubscribeRequests: false, + // Automatically send an authentication op/request after a connection opens, for private connections. authPrivateConnectionsOnConnect: true, // Individual requests do not require a signature, so this is disabled. authPrivateRequests: false, + ...options, }; } @@ -305,6 +310,9 @@ export abstract class BaseWebsocketClient< for (const requestKey in pendingSubReqs) { const request = pendingSubReqs[requestKey]; + this.logger.trace( + `clearTopicsPendingSubscriptions(${wsKey}, ${rejectAll}, ${rejectReason}, ${requestKey}): rejecting promise for: ${JSON.stringify(request?.requestData || {})}`, + ); request?.rejector(request.requestData, rejectReason); } } @@ -854,9 +862,11 @@ export abstract class BaseWebsocketClient< for (const midflightRequest of subscribeWsMessages) { const wsMessage = midflightRequest.requestEvent; - promises.push( - this.upsertPendingTopicSubscribeRequests(wsKey, midflightRequest), - ); + if (this.options.promiseSubscribeRequests) { + promises.push( + this.upsertPendingTopicSubscribeRequests(wsKey, midflightRequest), + ); + } this.logger.trace( `Sending batch via message: "${JSON.stringify(wsMessage)}"`, @@ -899,9 +909,11 @@ export abstract class BaseWebsocketClient< for (const midflightRequest of subscribeWsMessages) { const wsMessage = midflightRequest.requestEvent; - promises.push( - this.upsertPendingTopicSubscribeRequests(wsKey, midflightRequest), - ); + if (this.options.promiseSubscribeRequests) { + promises.push( + this.upsertPendingTopicSubscribeRequests(wsKey, midflightRequest), + ); + } this.logger.trace(`Sending batch via message: "${wsMessage}"`); this.tryWsSend(wsKey, JSON.stringify(wsMessage)); @@ -1004,6 +1016,7 @@ export abstract class BaseWebsocketClient< } catch (e) { this.logger.error( 'Exception trying to resolve "connectionInProgress" promise', + e, ); } @@ -1073,6 +1086,7 @@ export abstract class BaseWebsocketClient< } catch (e) { this.logger.error( 'Exception trying to resolve "connectionInProgress" promise', + e, ); } @@ -1216,6 +1230,9 @@ export abstract class BaseWebsocketClient< if ( this.wsStore.getConnectionState(wsKey) !== WsConnectionStateEnum.CLOSING ) { + this.logger.trace( + `onWsClose(${wsKey}): rejecting all deferred promises...`, + ); // clean up any pending promises for this connection this.getWsStore().rejectAllDeferredPromises( wsKey, @@ -1230,6 +1247,9 @@ export abstract class BaseWebsocketClient< this.emit('reconnect', { wsKey, event }); } else { // clean up any pending promises for this connection + this.logger.trace( + `onWsClose(${wsKey}): rejecting all deferred promises...`, + ); this.getWsStore().rejectAllDeferredPromises(wsKey, 'disconnected'); this.setWsState(wsKey, WsConnectionStateEnum.INITIAL); this.emit('close', { wsKey, event }); diff --git a/src/util/websockets/WsStore.ts b/src/util/websockets/WsStore.ts index 2744deb..6b42b2b 100644 --- a/src/util/websockets/WsStore.ts +++ b/src/util/websockets/WsStore.ts @@ -205,6 +205,9 @@ export class WsStore< const promise = this.getDeferredPromise(wsKey, promiseRef); if (promise?.reject) { + this.logger.trace( + `rejectDeferredPromise(): rejecting ${wsKey}/${promiseRef}/${value}`, + ); promise.reject(value); } From 0952e5f747fd6163912e806c2cf24741fc67aeb0 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Mon, 17 Feb 2025 11:04:33 +0000 Subject: [PATCH 39/60] feat(v4.0.0-beta.6): sync with master. Promisified subscribe requests are disabled by default. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 35b92ac..1210de8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bybit-api", - "version": "4.0.0-beta.5", + "version": "4.0.0-beta.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "bybit-api", - "version": "4.0.0-beta.5", + "version": "4.0.0-beta.6", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/package.json b/package.json index ae5ab4a..343e974 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bybit-api", - "version": "4.0.0-beta.5", + "version": "4.0.0-beta.6", "description": "Complete & robust Node.js SDK for Bybit's REST APIs and WebSockets, with TypeScript & strong end to end tests.", "main": "lib/index.js", "types": "lib/index.d.ts", From a521338091d4abfed4d5a58705419d146a8fc745 Mon Sep 17 00:00:00 2001 From: JJ-Cro Date: Mon, 17 Feb 2025 12:14:14 +0100 Subject: [PATCH 40/60] feat(): updated types for Market and Trade categories --- src/rest-client-v5.ts | 30 ++++++++++++++++-------------- src/types/request/v5-market.ts | 1 + src/types/request/v5-trade.ts | 5 +++++ src/types/response/v5-market.ts | 9 +++++++++ src/types/response/v5-position.ts | 1 + src/types/response/v5-trade.ts | 2 ++ src/types/shared-v5.ts | 5 ++++- 7 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/rest-client-v5.ts b/src/rest-client-v5.ts index 0262485..9ffd24a 100644 --- a/src/rest-client-v5.ts +++ b/src/rest-client-v5.ts @@ -551,7 +551,9 @@ export class RestClientV5 extends BaseRestClient { cancelAllOrders( params: CancelAllOrdersParamsV5, - ): Promise> { + ): Promise< + APIResponseV3WithTime<{ list: OrderResultV5[]; success: string }> + > { return this.postPrivate('/v5/order/cancel-all', params); } @@ -566,6 +568,18 @@ export class RestClientV5 extends BaseRestClient { return this.getPrivate('/v5/order/history', params); } + /** + * Query users' execution records, sorted by execTime in descending order + * + * Unified account covers: Spot / Linear contract / Options + * Normal account covers: USDT perpetual / Inverse perpetual / Inverse futures + */ + getExecutionList( + params: GetExecutionListParamsV5, + ): Promise>> { + return this.getPrivate('/v5/execution/list', params); + } + /** * This endpoint allows you to place more than one order in a single request. * Covers: Option (UTA, UTA Pro) / USDT Perpetual, UDSC Perpetual, USDC Futures (UTA Pro) @@ -675,7 +689,7 @@ export class RestClientV5 extends BaseRestClient { * Only for institutional clients! */ setDisconnectCancelAllWindowV2(params: { - product: 'OPTION' | 'SPOT' | 'DERIVATIVES'; + product?: 'OPTION' | 'SPOT' | 'DERIVATIVES'; timeWindow: number; }): Promise> { return this.postPrivate('/v5/order/disconnected-cancel-all', params); @@ -811,18 +825,6 @@ export class RestClientV5 extends BaseRestClient { return this.postPrivate('/v5/position/add-margin', params); } - /** - * Query users' execution records, sorted by execTime in descending order - * - * Unified account covers: Spot / Linear contract / Options - * Normal account covers: USDT perpetual / Inverse perpetual / Inverse futures - */ - getExecutionList( - params: GetExecutionListParamsV5, - ): Promise>> { - return this.getPrivate('/v5/execution/list', params); - } - /** * Query user's closed profit and loss records. The results are sorted by createdTime in descending order. * diff --git a/src/types/request/v5-market.ts b/src/types/request/v5-market.ts index f5a3c4b..3d37b16 100644 --- a/src/types/request/v5-market.ts +++ b/src/types/request/v5-market.ts @@ -108,6 +108,7 @@ export interface GetInsuranceParamsV5 { export interface GetRiskLimitParamsV5 { category?: 'linear' | 'inverse'; symbol?: string; + cursor?: string; } export interface GetOptionDeliveryPriceParamsV5 { diff --git a/src/types/request/v5-trade.ts b/src/types/request/v5-trade.ts index 437c70c..76aad5a 100644 --- a/src/types/request/v5-trade.ts +++ b/src/types/request/v5-trade.ts @@ -8,6 +8,7 @@ import { OrderTriggerByV5, OrderTypeV5, PositionIdx, + StopOrderTypeV5, } from '../shared-v5'; export interface OrderParamsV5 { @@ -51,6 +52,7 @@ export interface AmendOrderParamsV5 { triggerPrice?: string; qty?: string; price?: string; + tpslMode?: 'Full' | 'Partial'; takeProfit?: string; stopLoss?: string; tpTriggerBy?: OrderTriggerByV5; @@ -86,6 +88,7 @@ export interface GetAccountHistoricOrdersParamsV5 { category: CategoryV5; symbol?: string; baseCoin?: string; + settleCoin?: string; orderId?: string; orderLinkId?: string; orderFilter?: OrderFilterV5; @@ -102,11 +105,13 @@ export interface CancelAllOrdersParamsV5 { baseCoin?: string; settleCoin?: string; orderFilter?: OrderFilterV5; + stopOrderType?: StopOrderTypeV5; } export interface BatchOrderParamsV5 { symbol: string; side: OrderSideV5; + isLeverage?: 0 | 1; orderType: OrderTypeV5; qty: string; price?: string; diff --git a/src/types/response/v5-market.ts b/src/types/response/v5-market.ts index 8041b6b..1a9cdee 100644 --- a/src/types/response/v5-market.ts +++ b/src/types/response/v5-market.ts @@ -163,6 +163,8 @@ export interface OrderbookResponseV5 { a: OrderbookLevelV5[]; ts: number; u: number; + seq: number; + cts: number; } export interface TickerLinearInverseV5 { @@ -192,6 +194,7 @@ export interface TickerLinearInverseV5 { preOpenPrice: string; preQty: string; curPreListingPhase: string; + basis: string; } export interface TickerOptionV5 { @@ -252,6 +255,10 @@ export interface PublicTradeV5 { side: OrderSideV5; time: string; isBlockTrade: boolean; + mP?: string; + iP?: string; + mIv?: string; + iv?: string; } /** @@ -279,6 +286,7 @@ export interface HistoricalVolatilityV5 { export interface InsuranceDataV5 { coin: string; + symbols: string; balance: string; value: string; } @@ -299,6 +307,7 @@ export interface RiskLimitV5 { isLowestRisk: 0 | 1; maxLeverage: string; mmDeduction: string; + nextPageCursor?: string; } /** @deprecated use DeliveryPriceV5 instead */ diff --git a/src/types/response/v5-position.ts b/src/types/response/v5-position.ts index 3500c50..eb09d36 100644 --- a/src/types/response/v5-position.ts +++ b/src/types/response/v5-position.ts @@ -108,6 +108,7 @@ export interface ExecutionV5 { underlyingPrice?: string; blockTradeId?: string; closedSize?: string; + seq: number; } export interface ClosedPnLV5 { diff --git a/src/types/response/v5-trade.ts b/src/types/response/v5-trade.ts index 8e61cfd..454823d 100644 --- a/src/types/response/v5-trade.ts +++ b/src/types/response/v5-trade.ts @@ -107,5 +107,7 @@ export interface SpotBorrowCheckResultV5 { side: OrderSideV5; maxTradeQty: string; maxTradeAmount: string; + spotMaxTradeQty: string; + spotMaxTradeAmount: string; borrowCoin: string; } diff --git a/src/types/shared-v5.ts b/src/types/shared-v5.ts index 514111f..b594b6d 100644 --- a/src/types/shared-v5.ts +++ b/src/types/shared-v5.ts @@ -142,7 +142,10 @@ export type StopOrderTypeV5 = | 'Stop' | 'PartialTakeProfit' | 'PartialStopLoss' - | 'tpslOrder'; + | 'tpslOrder' + | 'OcoOrder' + | 'MmRateClose' + | 'BidirectionalTpslOrder'; /** * Position index. Used to identify positions in different position modes. From d2f3e25ecc5f71a6df41524d97ec901bd43e90a0 Mon Sep 17 00:00:00 2001 From: JJ-Cro Date: Mon, 17 Feb 2025 12:49:20 +0100 Subject: [PATCH 41/60] feat(): pushed all account types --- src/rest-client-v5.ts | 28 +++++++++++++++------------- src/types/response/v5-account.ts | 8 +++++++- src/types/response/v5-position.ts | 1 + 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/rest-client-v5.ts b/src/rest-client-v5.ts index 9ffd24a..833e7cc 100644 --- a/src/rest-client-v5.ts +++ b/src/rest-client-v5.ts @@ -747,6 +747,7 @@ export class RestClientV5 extends BaseRestClient { } /** + * @deprecated * This endpoint sets the take profit/stop loss (TP/SL) mode to full or partial. * * Unified account covers: Linear contract; normal account covers: USDT perpetual, inverse perpetual, inverse futures. @@ -775,6 +776,7 @@ export class RestClientV5 extends BaseRestClient { } /** + * @deprecated * The risk limit will limit the maximum position value you can hold under different margin requirements. * If you want to hold a bigger position size, you need more margin. * @@ -1101,6 +1103,19 @@ export class RestClientV5 extends BaseRestClient { return this.getPrivate('/v5/account/info'); } + /** + * Query the DCP configuration of the account's contracts (USDT perpetual, USDC perpetual and USDC Futures) / spot / options. + * + * Only the configured main / sub account can query information from this API. Calling this API by an account always returns empty. + * + * INFO + * support linear contract (USDT, USDC Perp & USDC Futures) / Spot / Options only + * Unified account only + */ + getDCPInfo(): Promise> { + return this.getPrivate('/v5/account/query-dcp-info'); + } + /** * Query transaction logs in Unified account. */ @@ -1185,19 +1200,6 @@ export class RestClientV5 extends BaseRestClient { return this.getPrivate('/v5/account/mmp-state', { baseCoin }); } - /** - * Query the DCP configuration of the account's contracts (USDT perpetual, USDC perpetual and USDC Futures) / spot / options. - * - * Only the configured main / sub account can query information from this API. Calling this API by an account always returns empty. - * - * INFO - * support linear contract (USDT, USDC Perp & USDC Futures) / Spot / Options only - * Unified account only - */ - getDCPInfo(): Promise> { - return this.getPrivate('/v5/account/query-dcp-info'); - } - /** * ****** Asset APIs diff --git a/src/types/response/v5-account.ts b/src/types/response/v5-account.ts index b23884d..3bf10a3 100644 --- a/src/types/response/v5-account.ts +++ b/src/types/response/v5-account.ts @@ -14,7 +14,7 @@ export interface WalletBalanceV5Coin { free: string; // spot only locked: string; // spot only borrowAmount: string; - availableToBorrow: string; + availableToBorrow: string; // deprecated field availableToWithdraw: string; accruedInterest: string; totalOrderIM: string; @@ -23,6 +23,8 @@ export interface WalletBalanceV5Coin { unrealisedPnl: string; cumRealisedPnl: string; bonus: string; + marginCollateral: boolean; + collateralSwitch: boolean; } export interface WalletBalanceV5 { @@ -54,6 +56,9 @@ export interface BorrowHistoryRecordV5 { hourlyBorrowRate: string; InterestBearingBorrowSize: string; costExemption: string; + borrowAmount: string; + unrealisedLoss: string; + freeBorrowedAmount: string; } export interface CollateralInfoV5 { @@ -110,6 +115,7 @@ export interface TransactionLogV5 { change: string; cashBalance: string; feeRate: string; + bonusChange: string; tradeId: string; orderId: string; orderLinkId: string; diff --git a/src/types/response/v5-position.ts b/src/types/response/v5-position.ts index eb09d36..fa03842 100644 --- a/src/types/response/v5-position.ts +++ b/src/types/response/v5-position.ts @@ -29,6 +29,7 @@ export interface PositionV5 { bustPrice?: string; positionIM?: string; positionMM?: string; + positionBalance?: string; tpslMode?: TPSLModeV5; takeProfit?: string; stopLoss?: string; From 50a01d123e282276844c19d491572ee8bfc1a011 Mon Sep 17 00:00:00 2001 From: JJ-Cro Date: Mon, 17 Feb 2025 13:51:38 +0100 Subject: [PATCH 42/60] feat(): finished asset category types --- src/rest-client-v5.ts | 108 +++++++++++++++++---------------- src/types/request/v5-asset.ts | 6 +- src/types/response/v5-asset.ts | 30 ++++++++- 3 files changed, 88 insertions(+), 56 deletions(-) diff --git a/src/rest-client-v5.ts b/src/rest-client-v5.ts index 833e7cc..9cfb13b 100644 --- a/src/rest-client-v5.ts +++ b/src/rest-client-v5.ts @@ -191,6 +191,7 @@ import { VipCollateralCoinsV5, WalletBalanceV5, WithdrawParamsV5, + WithdrawableAmountV5, WithdrawalRecordV5, } from './types'; @@ -1206,20 +1207,6 @@ export class RestClientV5 extends BaseRestClient { * */ - /** - * Query the coin exchange records. - * - * CAUTION: You may experience long delays with this endpoint. - */ - getCoinExchangeRecords(params?: GetCoinExchangeRecordParamsV5): Promise< - APIResponseV3WithTime<{ - orderBody: CoinExchangeRecordV5[]; - nextPageCursor?: string; - }> - > { - return this.getPrivate('/v5/asset/exchange/order-record', params); - } - /** * Query option delivery records, sorted by deliveryTime in descending order. * @@ -1244,6 +1231,46 @@ export class RestClientV5 extends BaseRestClient { return this.getPrivate('/v5/asset/settlement-record', params); } + /** + * Query the coin exchange records. + * + * CAUTION: You may experience long delays with this endpoint. + */ + getCoinExchangeRecords(params?: GetCoinExchangeRecordParamsV5): Promise< + APIResponseV3WithTime<{ + orderBody: CoinExchangeRecordV5[]; + nextPageCursor?: string; + }> + > { + return this.getPrivate('/v5/asset/exchange/order-record', params); + } + + /** + * Query coin information, including chain information, withdraw and deposit status. + */ + getCoinInfo( + coin?: string, + ): Promise> { + return this.getPrivate( + '/v5/asset/coin/query-info', + coin ? { coin } : undefined, + ); + } + + /** + * Query the sub UIDs under a main UID + * + * CAUTION: Can query by the master UID's api key only + */ + getSubUID(): Promise< + APIResponseV3WithTime<{ + subMemberIds: string[]; + transferableSubMemberIds: string[]; + }> + > { + return this.getPrivate('/v5/asset/transfer/query-sub-member-list'); + } + /** * Query asset information. * @@ -1284,6 +1311,15 @@ export class RestClientV5 extends BaseRestClient { ); } + /** + * Query withdrawable amount. + */ + getWithdrawableAmount(params: { + coin: string; + }): Promise> { + return this.getPrivate('/v5/asset/withdraw/withdrawable-amount', params); + } + /** * Query the transferable coin list between each account type. */ @@ -1309,7 +1345,7 @@ export class RestClientV5 extends BaseRestClient { amount: string, fromAccountType: AccountTypeV5, toAccountType: AccountTypeV5, - ): Promise> { + ): Promise> { return this.postPrivate('/v5/asset/transfer/inter-transfer', { transferId, coin, @@ -1331,20 +1367,6 @@ export class RestClientV5 extends BaseRestClient { ); } - /** - * Query the sub UIDs under a main UID - * - * CAUTION: Can query by the master UID's api key only - */ - getSubUID(): Promise< - APIResponseV3WithTime<{ - subMemberIds: string[]; - transferableSubMemberIds: string[]; - }> - > { - return this.getPrivate('/v5/asset/transfer/query-sub-member-list'); - } - /** * Enable Universal Transfer for Sub UID * @@ -1369,7 +1391,7 @@ export class RestClientV5 extends BaseRestClient { */ createUniversalTransfer( params: UniversalTransferParamsV5, - ): Promise> { + ): Promise> { return this.postPrivate('/v5/asset/transfer/universal-transfer', params); } @@ -1400,7 +1422,7 @@ export class RestClientV5 extends BaseRestClient { nextPageCursor: string; }> > { - return this.get('/v5/asset/deposit/query-allowed-list', params); + return this.getPrivate('/v5/asset/deposit/query-allowed-list', params); } /** @@ -1491,6 +1513,7 @@ export class RestClientV5 extends BaseRestClient { } /** + * @deprecated - duplicate function, use getSubDepositAddress() instead * Query the deposit address information of SUB account. * * CAUTION @@ -1508,18 +1531,6 @@ export class RestClientV5 extends BaseRestClient { }); } - /** - * Query coin information, including chain information, withdraw and deposit status. - */ - getCoinInfo( - coin?: string, - ): Promise> { - return this.getPrivate( - '/v5/asset/coin/query-info', - coin ? { coin } : undefined, - ); - } - /** * Query withdrawal records. */ @@ -1529,15 +1540,6 @@ export class RestClientV5 extends BaseRestClient { return this.getPrivate('/v5/asset/withdraw/query-record', params); } - /** - * Query withdrawable amount. - */ - getWithdrawableAmount(params: { - coin: string; - }): Promise> { - return this.getPrivate('/v5/asset/withdraw/withdrawable-amount', params); - } - /** * Get Exchange Entity List. * @@ -1609,7 +1611,7 @@ export class RestClientV5 extends BaseRestClient { * Query the exchange result by sending quoteTxId. */ getConvertStatus(params: { - quoteTxId?: string; + quoteTxId: string; accountType: | 'eb_convert_funding' | 'eb_convert_uta' diff --git a/src/types/request/v5-asset.ts b/src/types/request/v5-asset.ts index c7483d7..f9c671e 100644 --- a/src/types/request/v5-asset.ts +++ b/src/types/request/v5-asset.ts @@ -18,6 +18,8 @@ export interface GetDeliveryRecordParamsV5 { export interface GetSettlementRecordParamsV5 { category: CategoryV5; symbol?: string; + startTime?: number; + endTime?: number; limit?: number; cursor?: string; } @@ -100,6 +102,7 @@ export interface GetSubAccountDepositRecordParamsV5 { } export interface GetInternalDepositRecordParamsV5 { + txID?: string; startTime?: number; endTime?: number; coin?: string; @@ -109,6 +112,7 @@ export interface GetInternalDepositRecordParamsV5 { export interface GetWithdrawalRecordsParamsV5 { withdrawID?: string; + txID?: string; coin?: string; withdrawType?: number; startTime?: number; @@ -129,7 +133,7 @@ export interface WithdrawParamsV5 { feeType?: 0 | 1; requestId?: string; beneficiary?: { - vaspEntityId: string; + vaspEntityId?: string; beneficiaryName?: string; }; } diff --git a/src/types/response/v5-asset.ts b/src/types/response/v5-asset.ts index fe43abf..92372b3 100644 --- a/src/types/response/v5-asset.ts +++ b/src/types/response/v5-asset.ts @@ -66,6 +66,8 @@ export interface AccountCoinBalanceV5 { walletBalance: string; transferBalance: string; bonus: string; + transferSafeAmount: string; + ltvTransferSafeAmount: string; }; } @@ -113,6 +115,9 @@ export interface DepositRecordV5 { confirmations: string; txIndex: string; blockHash: string; + batchReleaseLimit: string; + depositType: string; + fromAddress: string; } export interface InternalDepositRecordV5 { @@ -123,6 +128,7 @@ export interface InternalDepositRecordV5 { status: 1 | 2 | 3; address: string; createdTime: string; + txID: string; } export interface DepositAddressChainV5 { @@ -130,6 +136,8 @@ export interface DepositAddressChainV5 { addressDeposit: string; tagDeposit: string; chain: string; + batchReleaseLimit: string; + contractAddress: string; } export interface DepositAddressResultV5 { @@ -151,6 +159,8 @@ export interface CoinInfoV5 { minAccuracy: string; chainDeposit: string; chainWithdraw: string; + withdrawPercentageFee: string; + contractAddress: string; }[]; } @@ -169,6 +179,22 @@ export interface WithdrawalRecordV5 { updateTime: string; } +export interface WithdrawableAmountV5 { + limitAmountUsd: string; + withdrawableAmount: { + SPOT: { + coin: string; + withdrawableAmount: string; + availableBalance: string; + }; + FUND: { + coin: string; + withdrawableAmount: string; + availableBalance: string; + }; + }; +} + export interface VaspEntityV5 { vaspEntityId: string; vaspName: string; @@ -220,7 +246,7 @@ export interface ConvertStatusV5 { fromAmount: string; toAmount: string; exchangeStatus: 'init' | 'processing' | 'success' | 'failure'; - extInfo: object; + extInfo: { paramType: string; paramValue: string }; convertRate: string; createdAt: string; } @@ -236,7 +262,7 @@ export interface ConvertHistoryRecordV5 { fromAmount: string; toAmount: string; exchangeStatus: 'init' | 'processing' | 'success' | 'failure'; - extInfo: object; + extInfo: { paramType: string; paramValue: string }; convertRate: string; createdAt: string; } From 2ba75d6bdbf17856589734e9082010ccfe300477 Mon Sep 17 00:00:00 2001 From: JJ-Cro Date: Mon, 17 Feb 2025 15:30:38 +0100 Subject: [PATCH 43/60] feat(): updated all remaining categories for response and request types --- src/rest-client-v5.ts | 50 +++++++++++++++------------- src/types/response/v5-crypto-loan.ts | 1 - src/types/response/v5-user.ts | 38 +++++++++++++++------ 3 files changed, 54 insertions(+), 35 deletions(-) diff --git a/src/rest-client-v5.ts b/src/rest-client-v5.ts index 9cfb13b..625f7a2 100644 --- a/src/rest-client-v5.ts +++ b/src/rest-client-v5.ts @@ -170,7 +170,6 @@ import { SpotBorrowCheckResultV5, SpotLeveragedTokenOrderHistoryV5, SpotMarginStateV5, - SubAccountAllApiKeysResultV5, SubMemberV5, SwitchIsolatedMarginParamsV5, SwitchPositionModeParamsV5, @@ -1691,15 +1690,6 @@ export class RestClientV5 extends BaseRestClient { return this.getPrivate('/v5/user/submembers', params); } - /** - * Query all api keys information of a sub UID. - */ - getSubAccountAllApiKeys( - params: GetSubAccountAllApiKeysParamsV5, - ): Promise> { - return this.getPrivate('/v5/user/sub-apikeys', params); - } - /** * Froze sub uid. Use master user's api key only. * @@ -1723,6 +1713,18 @@ export class RestClientV5 extends BaseRestClient { return this.getPrivate('/v5/user/query-api'); } + /** + * Query all api keys information of a sub UID. + */ + getSubAccountAllApiKeys(params: GetSubAccountAllApiKeysParamsV5): Promise< + APIResponseV3WithTime<{ + result: ApiKeyInfoV5[]; + nextPageCursor: string; + }> + > { + return this.getPrivate('/v5/user/sub-apikeys', params); + } + getUIDWalletType(params: { memberIds: string }): Promise< APIResponseV3WithTime<{ accounts: { @@ -1731,7 +1733,7 @@ export class RestClientV5 extends BaseRestClient { }[]; }> > { - return this.getPrivate('/v5/user/query-api', params); + return this.getPrivate('/v5/user/get-member-type', params); } /** @@ -1760,6 +1762,19 @@ export class RestClientV5 extends BaseRestClient { return this.postPrivate('/v5/user/update-sub-api', params); } + /** + * Delete a sub UID. Before deleting the UID, please make sure there are no assets. + * + * TIP: + * The API key must have one of the permissions to be allowed to call the following API endpoint. + * - master API key: "Account Transfer", "Subaccount Transfer", "Withdrawal" + */ + deleteSubMember( + params: DeleteSubMemberParamsV5, + ): Promise> { + return this.postPrivate('/v5/user/del-submember', params); + } + /** * Delete the api key of master account. Use the api key pending to be delete to call the endpoint. Use master user's api key only. * @@ -1789,19 +1804,6 @@ export class RestClientV5 extends BaseRestClient { return this.postPrivate('/v5/user/delete-sub-api', params); } - /** - * Delete a sub UID. Before deleting the UID, please make sure there are no assets. - * - * TIP: - * The API key must have one of the permissions to be allowed to call the following API endpoint. - * - master API key: "Account Transfer", "Subaccount Transfer", "Withdrawal" - */ - deleteSubMember( - params: DeleteSubMemberParamsV5, - ): Promise> { - return this.postPrivate('/v5/user/del-submember', params); - } - /** * ****** Affiliate APIs diff --git a/src/types/response/v5-crypto-loan.ts b/src/types/response/v5-crypto-loan.ts index e379de7..4074fe4 100644 --- a/src/types/response/v5-crypto-loan.ts +++ b/src/types/response/v5-crypto-loan.ts @@ -1,6 +1,5 @@ export interface CollateralCoinV5 { collateralAccuracy: number; - currency: string; initialLTV: string; liquidationLTV: string; marginCallLTV: string; diff --git a/src/types/response/v5-user.ts b/src/types/response/v5-user.ts index b74c579..eb884aa 100644 --- a/src/types/response/v5-user.ts +++ b/src/types/response/v5-user.ts @@ -27,26 +27,44 @@ export interface SubMemberV5 { } export type ApiKeyType = 1 | 2; +export interface ApiKeyPermissionsV5 { + ContractTrade: string[]; + Spot: string[]; + Wallet: string[]; + Options: string[]; + Derivatives: string[]; + CopyTrading: string[]; + BlockTrade: string[]; + Exchange: string[]; + NFT: string[]; + Affiliate: string[]; +} + export interface ApiKeyInfoV5 { id: string; note: string; apiKey: string; readOnly: 0 | 1; secret: string; - permissions: PermissionsV5; - ips?: string[]; - type: ApiKeyType; - deadlineDay?: number; - expiredAt?: string; + permissions: ApiKeyPermissionsV5; + ips: string[]; + type: 1 | 2; // 1: personal, 2: connected to third-party app + deadlineDay: number; + expiredAt: string; createdAt: string; - unified: 0 | 1; - uta: 0 | 1; + /** @deprecated */ + unified: number; + uta: 0 | 1; // 0: regular account, 1: unified trade account userID: number; inviterID: number; - vipLevel?: string; - mktMakerLevel?: string; - affiliateID?: number; + vipLevel: string; + mktMakerLevel: string; + affiliateID: number; + rsaPublicKey: string; isMaster: boolean; + parentUid: string; + kycLevel: 'LEVEL_DEFAULT' | 'LEVEL_1' | 'LEVEL_2'; + kycRegion: string; } export interface UpdateApiKeyResultV5 { From 9cbb1da10111f8ba831c06189b96305eae6d380e Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Mon, 17 Feb 2025 14:56:17 +0000 Subject: [PATCH 44/60] feat(v4.0.0-beta.7, #419): add/update REST types matching latest changes in api docs --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1210de8..204ddab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bybit-api", - "version": "4.0.0-beta.6", + "version": "4.0.0-beta.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "bybit-api", - "version": "4.0.0-beta.6", + "version": "4.0.0-beta.7", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/package.json b/package.json index 343e974..faa2c9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bybit-api", - "version": "4.0.0-beta.6", + "version": "4.0.0-beta.7", "description": "Complete & robust Node.js SDK for Bybit's REST APIs and WebSockets, with TypeScript & strong end to end tests.", "main": "lib/index.js", "types": "lib/index.d.ts", From 355756723f2c28b7ec6d795de9e7cc94a7ad005d Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 18 Feb 2025 11:02:47 +0000 Subject: [PATCH 45/60] chore(): update examples for exception event --- README.md | 4 ++-- examples/ws-api-events.ts | 4 ++-- examples/ws-private-v5.ts | 2 +- test/ws.util.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8f94d49..ed26439 100644 --- a/README.md +++ b/README.md @@ -377,8 +377,8 @@ ws.on('close', () => { }); // Listen to raw error events. Recommended. -ws.on('error', (err) => { - console.error('error', err); +ws.on('exception', (err) => { + console.error('exception', err); }); ws.on('reconnect', ({ wsKey }) => { diff --git a/examples/ws-api-events.ts b/examples/ws-api-events.ts index 8e3a9c9..ce47005 100644 --- a/examples/ws-api-events.ts +++ b/examples/ws-api-events.ts @@ -69,8 +69,8 @@ async function main() { const CANCEL_AFTER_SECONDS = 5; // Exceptions including rejected commands will show here (as well as the catch handler used below) - wsClient.on('error', (data) => { - console.error('ws error: ', data); + wsClient.on('exception', (data) => { + console.error('ws exception: ', data); }); // Replies to commands will show here diff --git a/examples/ws-private-v5.ts b/examples/ws-private-v5.ts index f9a4f1a..f7d91e6 100644 --- a/examples/ws-private-v5.ts +++ b/examples/ws-private-v5.ts @@ -49,7 +49,7 @@ wsClient.on('reconnect', ({ wsKey }) => { wsClient.on('reconnected', (data) => { console.log('ws has reconnected ', data?.wsKey); }); -// wsClient.on('error', (data) => { +// wsClient.on('exception', (data) => { // console.error('ws exception: ', data); // }); diff --git a/test/ws.util.ts b/test/ws.util.ts index db82eee..8e48777 100644 --- a/test/ws.util.ts +++ b/test/ws.util.ts @@ -21,7 +21,7 @@ export type WsClientEvent = | 'open' | 'update' | 'close' - | 'error' + | 'exception' | 'reconnect' | 'reconnected' | 'response'; @@ -46,7 +46,7 @@ export function waitForSocketEvent( clearTimeout(timeout); resolvedOnce = true; wsClient.removeListener(event, (e) => resolver(e)); - wsClient.removeListener('error', (e) => rejector(e)); + wsClient.removeListener('exception', (e) => rejector(e)); } function resolver(event) { From 3ff903012a7e05df15c6076359da57bb25e79622 Mon Sep 17 00:00:00 2001 From: JJ-Cro Date: Thu, 20 Feb 2025 11:48:54 +0100 Subject: [PATCH 46/60] feat(): added new rest EARN endpoints --- examples/apidoc/V5/Earn/get-product-info.js | 19 +++++ .../V5/Earn/get-stake-redeem-order-history.js | 19 +++++ .../apidoc/V5/Earn/get-staked-position.js | 19 +++++ examples/apidoc/V5/Earn/stake-redeem.js | 24 ++++++ src/rest-client-v5.ts | 79 +++++++++++++++++++ src/types/request/v5-earn.ts | 21 +++++ src/types/response/v5-earn.ts | 30 +++++++ 7 files changed, 211 insertions(+) create mode 100644 examples/apidoc/V5/Earn/get-product-info.js create mode 100644 examples/apidoc/V5/Earn/get-stake-redeem-order-history.js create mode 100644 examples/apidoc/V5/Earn/get-staked-position.js create mode 100644 examples/apidoc/V5/Earn/stake-redeem.js create mode 100644 src/types/request/v5-earn.ts create mode 100644 src/types/response/v5-earn.ts diff --git a/examples/apidoc/V5/Earn/get-product-info.js b/examples/apidoc/V5/Earn/get-product-info.js new file mode 100644 index 0000000..5421ea3 --- /dev/null +++ b/examples/apidoc/V5/Earn/get-product-info.js @@ -0,0 +1,19 @@ +const { RestClientV5 } = require('bybit-api'); + +const client = new RestClientV5({ + testnet: true, + key: 'apikey', + secret: 'apisecret', +}); + +client + .getEarnProduct({ + category: 'FlexibleSaving', + coin: 'BTC', + }) + .then((response) => { + console.log(response); + }) + .catch((error) => { + console.error(error); + }); diff --git a/examples/apidoc/V5/Earn/get-stake-redeem-order-history.js b/examples/apidoc/V5/Earn/get-stake-redeem-order-history.js new file mode 100644 index 0000000..5f7200e --- /dev/null +++ b/examples/apidoc/V5/Earn/get-stake-redeem-order-history.js @@ -0,0 +1,19 @@ +const { RestClientV5 } = require('bybit-api'); + +const client = new RestClientV5({ + testnet: true, + key: 'apikey', + secret: 'apisecret', +}); + +client + .getEarnOrderHistory({ + category: 'FlexibleSaving', + orderId: '0572b030-6a0b-423f-88c4-b6ce31c0c82d', + }) + .then((response) => { + console.log(response); + }) + .catch((error) => { + console.error(error); + }); diff --git a/examples/apidoc/V5/Earn/get-staked-position.js b/examples/apidoc/V5/Earn/get-staked-position.js new file mode 100644 index 0000000..fd49487 --- /dev/null +++ b/examples/apidoc/V5/Earn/get-staked-position.js @@ -0,0 +1,19 @@ +const { RestClientV5 } = require('bybit-api'); + +const client = new RestClientV5({ + testnet: true, + key: 'apikey', + secret: 'apisecret', +}); + +client + .getEarnPosition({ + category: 'FlexibleSaving', + coin: 'USDT', + }) + .then((response) => { + console.log(response); + }) + .catch((error) => { + console.error(error); + }); diff --git a/examples/apidoc/V5/Earn/stake-redeem.js b/examples/apidoc/V5/Earn/stake-redeem.js new file mode 100644 index 0000000..178d970 --- /dev/null +++ b/examples/apidoc/V5/Earn/stake-redeem.js @@ -0,0 +1,24 @@ +const { RestClientV5 } = require('bybit-api'); + +const client = new RestClientV5({ + testnet: true, + key: 'apikey', + secret: 'apisecret', +}); + +client + .submitStakeRedeem({ + category: 'FlexibleSaving', + orderType: 'Stake', + accountType: 'FUND', + amount: '0.35', + coin: 'BTC', + productId: '430', + orderLinkId: 'btc-earn-001', + }) + .then((response) => { + console.log(response); + }) + .catch((error) => { + console.error(error); + }); diff --git a/src/rest-client-v5.ts b/src/rest-client-v5.ts index fc48402..7a84e1a 100644 --- a/src/rest-client-v5.ts +++ b/src/rest-client-v5.ts @@ -193,6 +193,16 @@ import { WithdrawableAmountV5, WithdrawalRecordV5, } from './types'; +import { + GetEarnOrderHistoryParams, + GetEarnPositionParams, + SubmitStakeRedeemParams, +} from './types/request/v5-earn'; +import { + EarnOrderHistory, + EarnPosition, + EarnProduct, +} from './types/response/v5-earn'; import { REST_CLIENT_TYPE_ENUM } from './util'; import BaseRestClient from './util/BaseRestClient'; @@ -2546,4 +2556,73 @@ export class RestClientV5 extends BaseRestClient { ): Promise> { return this.postPrivate('/v5/broker/award/distribution-record', params); } + + /** + * + ****** EARN + * + */ + + /** + * Get Product Info for Earn products + * + * INFO: Do not need authentication + */ + getEarnProduct(params: { category: string; coin?: string }): Promise< + APIResponseV3WithTime<{ + list: EarnProduct[]; + }> + > { + return this.get('/v5/earn/product', params); + } + + /** + * Stake or Redeem Earn products + * + * INFO: API key needs "Earn" permission + * + * NOTE: In times of high demand for loans in the market for a specific cryptocurrency, + * the redemption of the principal may encounter delays and is expected to be processed + * within 48 hours. Once the redemption request is initiated, it cannot be canceled, + * and your principal will continue to earn interest until the process is completed. + */ + submitStakeRedeem(params: SubmitStakeRedeemParams): Promise< + APIResponseV3WithTime<{ + orderId: string; + orderLinkId: string; + }> + > { + return this.postPrivate('/v5/earn/place-order', params); + } + + /** + * Get Stake/Redeem Order History + * + * INFO: API key needs "Earn" permission + * + * Note: Either orderId or orderLinkId is required. If both are passed, + * make sure they're matched, otherwise returning empty result + */ + getEarnOrderHistory(params: GetEarnOrderHistoryParams): Promise< + APIResponseV3WithTime<{ + list: EarnOrderHistory[]; + }> + > { + return this.getPrivate('/v5/earn/order', params); + } + + /** + * Get Staked Position + * + * INFO: API key needs "Earn" permission + * + * Note: Fully redeemed position is also returned in the response + */ + getEarnPosition(params: GetEarnPositionParams): Promise< + APIResponseV3WithTime<{ + list: EarnPosition[]; + }> + > { + return this.getPrivate('/v5/earn/position', params); + } } diff --git a/src/types/request/v5-earn.ts b/src/types/request/v5-earn.ts new file mode 100644 index 0000000..b25c354 --- /dev/null +++ b/src/types/request/v5-earn.ts @@ -0,0 +1,21 @@ +export interface SubmitStakeRedeemParams { + category: string; + orderType: 'Stake' | 'Redeem'; + accountType: 'FUND' | 'UNIFIED'; + amount: string; + coin: string; + productId: string; + orderLinkId: string; +} + +export interface GetEarnOrderHistoryParams { + category: string; + orderId?: string; + orderLinkId?: string; +} + +export interface GetEarnPositionParams { + category: string; + productId?: string; + coin?: string; +} diff --git a/src/types/response/v5-earn.ts b/src/types/response/v5-earn.ts new file mode 100644 index 0000000..e25a07a --- /dev/null +++ b/src/types/response/v5-earn.ts @@ -0,0 +1,30 @@ +export interface EarnProduct { + category: string; + estimateApr: string; + coin: string; + minStakeAmount: string; + maxStakeAmount: string; + precision: string; + productId: string; + status: 'Available' | 'NotAvailable'; +} + +export interface EarnOrderHistory { + coin: string; + orderValue: string; + orderType: 'Redeem' | 'Stake'; + orderId: string; + orderLinkId: string; + status: 'Success' | 'Fail' | 'Pending'; + createdAt: string; + productId: string; + updatedAt: string; +} + +export interface EarnPosition { + coin: string; + productId: string; + amount: string; + totalPnl: string; + claimableYield: string; +} From 5005b816b34a1924801251da712c58e47b417c7c Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Wed, 26 Feb 2025 10:16:39 +0000 Subject: [PATCH 47/60] feat(): add all symbols allLiquidaitons ws example --- examples/ws-public-allLiquidations.ts | 92 +++++++++++++++++++++++++++ src/util/typeGuards.ts | 37 +++++++++++ 2 files changed, 129 insertions(+) create mode 100644 examples/ws-public-allLiquidations.ts diff --git a/examples/ws-public-allLiquidations.ts b/examples/ws-public-allLiquidations.ts new file mode 100644 index 0000000..98983ed --- /dev/null +++ b/examples/ws-public-allLiquidations.ts @@ -0,0 +1,92 @@ +import { RestClientV5, WebsocketClient, isWsAllLiquidationEvent } from '../src'; + +// or +// import { +// RestClientV5, +// WebsocketClient, +// isWsAllLiquidationEvent, +// } from 'bybit-api'; + +function onAllLiquidationEvent(event) { + console.log( + new Date(), + 'allLiquidationEvent', + JSON.stringify(event, null, 2), + ); +} + +const wsClient = new WebsocketClient({}); + +wsClient.on('update', (data) => { + if (isWsAllLiquidationEvent(data)) { + return onAllLiquidationEvent(data); + } + + console.log('raw unahndled message received ', JSON.stringify(data)); +}); + +wsClient.on('open', (data) => { + console.log('connection opened open:', data.wsKey); +}); +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); +}); + +wsClient.on('exception', (data) => { + console.error('ws exception: ', data); +}); + +/** + * + * If you want to receive data for all available symbols, this websocket topic + * requires you to subscribe to each symbol individually. + * + * This can be easily automated by fetching a list of symbols via the REST client, + * generating a list of topics (one per symbol), before simply passing an + * array of topics to the websocket client per product group (linear & inverse perps). + * + */ +async function start() { + const restClientV5 = new RestClientV5(); + + const allSymbolsV5ResultLinear = await restClientV5.getTickers({ + category: 'linear', + }); + const allSymbolsV5ResultInverse = await restClientV5.getTickers({ + category: 'inverse', + }); + + const allLinearSymbols = allSymbolsV5ResultLinear.result.list.map( + (ticker) => ticker.symbol, + ); + const allInverseSymbols = allSymbolsV5ResultInverse.result.list.map( + (ticker) => ticker.symbol, + ); + + console.log('all v5 linear symbols: ', JSON.stringify(allLinearSymbols)); + console.log('all v5 inverse symbols: ', JSON.stringify(allInverseSymbols)); + + const TOPIC_NAME = 'allLiquidation'; + + // Make an array of topics ready for submission + const allLinearTopics = allLinearSymbols.map( + (symbol) => `${TOPIC_NAME}.${symbol}`, + ); + const inverseTopics = allInverseSymbols.map( + (symbol) => `${TOPIC_NAME}.${symbol}`, + ); + + // subscribe to all linear symbols + wsClient.subscribeV5(allLinearTopics, 'linear'); + + // subscribe to all inverse symbols + wsClient.subscribeV5(inverseTopics, 'inverse'); +} + +start().catch((e) => console.error('exception in main logic: ', e)); diff --git a/src/util/typeGuards.ts b/src/util/typeGuards.ts index f721f99..bd9c7c4 100644 --- a/src/util/typeGuards.ts +++ b/src/util/typeGuards.ts @@ -14,6 +14,29 @@ import { WSPositionEventV5, } from '../types/websockets/ws-events'; +export interface BybitEventV5 { + topic: string; + type: string; + ts: number; + data: TData; + wsKey: string; +} + +export function isWsEventV5( + event: unknown, +): event is BybitEventV5 { + if ( + typeof event !== 'object' || + !event || + typeof event['topic'] !== 'string' || + typeof event['type'] !== 'string' + ) { + return false; + } + + return true; +} + /** * Type guard to detect a V5 orderbook event (delta & snapshots) * @@ -141,3 +164,17 @@ export function isTopicSubscriptionConfirmation( return true; } + +export function isWsAllLiquidationEvent( + event: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): event is BybitEventV5 { + if (!isWsEventV5(event)) { + return false; + } + + if (event['topic'].startsWith('allLiquidation')) { + return true; + } + return false; +} From 446db1ae2c6f2b78ac68a75af797bdc71c698df6 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Wed, 26 Feb 2025 10:33:13 +0000 Subject: [PATCH 48/60] feat(v4.0.0-beta.8): add support for new Websocket API batch order commands --- package-lock.json | 4 ++-- package.json | 6 ++---- src/types/websockets/ws-api.ts | 34 ++++++++++++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 204ddab..5f09f6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bybit-api", - "version": "4.0.0-beta.7", + "version": "4.0.0-beta.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "bybit-api", - "version": "4.0.0-beta.7", + "version": "4.0.0-beta.8", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/package.json b/package.json index faa2c9b..d9842b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bybit-api", - "version": "4.0.0-beta.7", + "version": "4.0.0-beta.8", "description": "Complete & robust Node.js SDK for Bybit's REST APIs and WebSockets, with TypeScript & strong end to end tests.", "main": "lib/index.js", "types": "lib/index.d.ts", @@ -20,9 +20,7 @@ "betapublish": "npm publish --tag beta" }, "author": "Tiago Siebler (https://github.com/tiagosiebler)", - "contributors": [ - "Stefan Aebischer (https://pixtron.ch)" - ], + "contributors": [], "dependencies": { "axios": "^1.7.9", "isomorphic-ws": "^4.0.1", diff --git a/src/types/websockets/ws-api.ts b/src/types/websockets/ws-api.ts index f47c562..c0d5295 100644 --- a/src/types/websockets/ws-api.ts +++ b/src/types/websockets/ws-api.ts @@ -1,13 +1,28 @@ import { APIID, WS_KEY_MAP } from '../../util'; import { AmendOrderParamsV5, + BatchAmendOrderParamsV5, + BatchCancelOrderParamsV5, + BatchOrderParamsV5, CancelOrderParamsV5, OrderParamsV5, } from '../request'; -import { OrderResultV5 } from '../response'; +import { + BatchAmendOrderResultV5, + BatchCancelOrderResultV5, + BatchCreateOrderResultV5, + BatchOrdersResponseV5, + OrderResultV5, +} from '../response'; import { WsKey } from './ws-general'; -export type WSAPIOperation = 'order.create' | 'order.amend' | 'order.cancel'; +export type WSAPIOperation = + | 'order.create' + | 'order.amend' + | 'order.cancel' + | 'order.create-batch' + | 'order.amend-batch' + | 'order.cancel-batch'; export type WsOperation = | 'subscribe' @@ -85,6 +100,9 @@ export interface WsAPITopicRequestParamMap { 'order.create': OrderParamsV5; 'order.amend': AmendOrderParamsV5; 'order.cancel': CancelOrderParamsV5; + 'order.create-batch': BatchOrderParamsV5[]; + 'order.amend-batch': BatchAmendOrderParamsV5[]; + 'order.cancel-batch': BatchCancelOrderParamsV5[]; } /** @@ -94,6 +112,18 @@ export interface WsAPIOperationResponseMap { 'order.create': WSAPIResponse; 'order.amend': WSAPIResponse; 'order.cancel': WSAPIResponse; + 'order.create-batch': WSAPIResponse< + BatchOrdersResponseV5, + 'order.create-batch' + >; + 'order.amend-batch': WSAPIResponse< + BatchOrdersResponseV5, + 'order.amend-batch' + >; + 'order.cancel-batch': WSAPIResponse< + BatchOrdersResponseV5, + 'order.cancel-batch' + >; ping: { retCode: 0 | number; retMsg: 'OK' | string; From d638cf174aeb563b6d4dad4a813dd3257fc4fce0 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Fri, 28 Feb 2025 13:20:45 +0000 Subject: [PATCH 49/60] fix(): request type for wsapi batch order commands --- src/types/websockets/ws-api.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/types/websockets/ws-api.ts b/src/types/websockets/ws-api.ts index c0d5295..962a232 100644 --- a/src/types/websockets/ws-api.ts +++ b/src/types/websockets/ws-api.ts @@ -100,9 +100,18 @@ export interface WsAPITopicRequestParamMap { 'order.create': OrderParamsV5; 'order.amend': AmendOrderParamsV5; 'order.cancel': CancelOrderParamsV5; - 'order.create-batch': BatchOrderParamsV5[]; - 'order.amend-batch': BatchAmendOrderParamsV5[]; - 'order.cancel-batch': BatchCancelOrderParamsV5[]; + 'order.create-batch': { + category: 'option' | 'linear'; + orders: BatchOrderParamsV5[]; + }; + 'order.amend-batch': { + category: 'option' | 'linear'; + orders: BatchAmendOrderParamsV5[]; + }; + 'order.cancel-batch': { + category: 'option' | 'linear'; + orders: BatchCancelOrderParamsV5[]; + }; } /** From f45018da883c1534cdb8ec34c98fec6637a61b51 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Mon, 3 Mar 2025 10:45:28 +0000 Subject: [PATCH 50/60] v4.0.0-beta.9: fix() batch order ws api handling --- package-lock.json | 4 ++-- package.json | 2 +- src/types/response/v5-trade.ts | 14 ++++++----- src/types/websockets/ws-api.ts | 44 ++++++++++++++++++++++------------ 4 files changed, 40 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5f09f6b..76fb1b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bybit-api", - "version": "4.0.0-beta.8", + "version": "4.0.0-beta.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "bybit-api", - "version": "4.0.0-beta.8", + "version": "4.0.0-beta.9", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/package.json b/package.json index d9842b4..ad975ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bybit-api", - "version": "4.0.0-beta.8", + "version": "4.0.0-beta.9", "description": "Complete & robust Node.js SDK for Bybit's REST APIs and WebSockets, with TypeScript & strong end to end tests.", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/src/types/response/v5-trade.ts b/src/types/response/v5-trade.ts index f7eba1a..0e591d9 100644 --- a/src/types/response/v5-trade.ts +++ b/src/types/response/v5-trade.ts @@ -78,16 +78,18 @@ export interface BatchCreateOrderResultV5 { createAt: string; } +export interface BatchOrdersRetExtInfoV5 { + list: { + code: number; + msg: string; + }[]; +} + export interface BatchOrdersResponseV5 { result: { list: T; }; - retExtInfo: { - list: { - code: number; - msg: string; - }[]; - }; + retExtInfo: BatchOrdersRetExtInfoV5; } export interface BatchAmendOrderResultV5 { diff --git a/src/types/websockets/ws-api.ts b/src/types/websockets/ws-api.ts index 962a232..7431dac 100644 --- a/src/types/websockets/ws-api.ts +++ b/src/types/websockets/ws-api.ts @@ -12,10 +12,12 @@ import { BatchCancelOrderResultV5, BatchCreateOrderResultV5, BatchOrdersResponseV5, + BatchOrdersRetExtInfoV5, OrderResultV5, } from '../response'; import { WsKey } from './ws-general'; +// When new WS API operations are added, make sure to also update WS_API_Operations[] below export type WSAPIOperation = | 'order.create' | 'order.amend' @@ -24,6 +26,15 @@ export type WSAPIOperation = | 'order.amend-batch' | 'order.cancel-batch'; +export const WS_API_Operations: WSAPIOperation[] = [ + 'order.create', + 'order.amend', + 'order.cancel', + 'order.create-batch', + 'order.amend-batch', + 'order.cancel-batch', +]; + export type WsOperation = | 'subscribe' | 'unsubscribe' @@ -31,12 +42,6 @@ export type WsOperation = | 'ping' | 'pong'; -export const WS_API_Operations: WSAPIOperation[] = [ - 'order.create', - 'order.amend', - 'order.cancel', -]; - export interface WsRequestOperationBybit { req_id: string; op: WsOperation; @@ -100,17 +105,18 @@ export interface WsAPITopicRequestParamMap { 'order.create': OrderParamsV5; 'order.amend': AmendOrderParamsV5; 'order.cancel': CancelOrderParamsV5; + 'order.create-batch': { category: 'option' | 'linear'; - orders: BatchOrderParamsV5[]; + request: BatchOrderParamsV5[]; }; 'order.amend-batch': { category: 'option' | 'linear'; - orders: BatchAmendOrderParamsV5[]; + request: BatchAmendOrderParamsV5[]; }; 'order.cancel-batch': { category: 'option' | 'linear'; - orders: BatchCancelOrderParamsV5[]; + request: BatchCancelOrderParamsV5[]; }; } @@ -121,18 +127,26 @@ export interface WsAPIOperationResponseMap { 'order.create': WSAPIResponse; 'order.amend': WSAPIResponse; 'order.cancel': WSAPIResponse; + 'order.create-batch': WSAPIResponse< - BatchOrdersResponseV5, + { list: BatchCreateOrderResultV5[] }, 'order.create-batch' - >; + > & { + retExtInfo: BatchOrdersRetExtInfoV5; + }; 'order.amend-batch': WSAPIResponse< - BatchOrdersResponseV5, + { list: BatchAmendOrderResultV5[] }, 'order.amend-batch' - >; + > & { + retExtInfo: BatchOrdersRetExtInfoV5; + }; 'order.cancel-batch': WSAPIResponse< - BatchOrdersResponseV5, + { list: BatchCancelOrderResultV5[] }, 'order.cancel-batch' - >; + > & { + retExtInfo: BatchOrdersRetExtInfoV5; + }; + ping: { retCode: 0 | number; retMsg: 'OK' | string; From d8f4b1e4b4ef0214f443d8a9f173f88cab8adcef Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Mon, 3 Mar 2025 10:46:48 +0000 Subject: [PATCH 51/60] chore(): remove unused import --- src/types/websockets/ws-api.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/types/websockets/ws-api.ts b/src/types/websockets/ws-api.ts index 7431dac..ffbfed9 100644 --- a/src/types/websockets/ws-api.ts +++ b/src/types/websockets/ws-api.ts @@ -11,7 +11,6 @@ import { BatchAmendOrderResultV5, BatchCancelOrderResultV5, BatchCreateOrderResultV5, - BatchOrdersResponseV5, BatchOrdersRetExtInfoV5, OrderResultV5, } from '../response'; From 5a513ea64ebf8c0a3c7a4d8d7639b4404f8beb3a Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 4 Mar 2025 11:08:37 +0000 Subject: [PATCH 52/60] feat(v4.0.0): WebSocket API, WebsocketClient upgrades, decommission deprecated v1-v3 API groups --- README.md | 32 ++++++++++++++++++-------------- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ed26439..889809c 100644 --- a/README.md +++ b/README.md @@ -19,22 +19,23 @@ [1]: https://www.npmjs.com/package/bybit-api -Node.js, JavaScript & TypeScript SDK for the Bybit REST APIs and WebSockets: +Professional Node.js, JavaScript & TypeScript SDK for the Bybit REST APIs and WebSockets: - Complete integration with all Bybit REST APIs & WebSockets, including the WebSocket API. - Actively maintained with a modern, promise-driven interface. -- TypeScript support (with type declarations for most API requests & responses). -- Thorough end-to-end tests making real API calls & WebSocket connections, validating any changes before they reach npm. +- TypeScript support (thorough type declarations for most API requests & responses, including WS API). +- JavaScript support (TypeScript not required but definitely recommended). +- Thorough & automatic end-to-end tests making real API calls & WebSocket connections, validating any changes before they reach npm. - Proxy support via axios integration. -- Robust WebSocket integration with configurable connection heartbeats & automatic reconnect then resubscribe workflows. - - Event driven messaging. +- Robust WebSocket consumer integration with configurable heartbeats & automatic reconnect then resubscribe workflows. + - Event driven messaging - Smart websocket persistence - Automatically handle silent websocket disconnections through timed heartbeats, including the scheduled 24hr disconnect. - - Automatically handle listenKey persistence and expiration/refresh. + - Automatically handle authentication. - Emit `reconnected` event when dropped connection is restored. - WebSocket API integration, with two design patterns to choose from: - Asynchronous promise-driven responses: - - This behaves very much like a REST API. No need to subscribe to asynchronous events. + - Make requests like a REST API, using the WebSocket API. No need to subscribe to asynchronous events. - Send commands with the await sendWSAPIRequest(...) method. - Await responses to commands directly in the fully typed sendWSAPIRequest() call. - The method directly returns a promise. Use a try/catch block for convenient error handling without the complexity of asynchronous WebSockets. @@ -124,7 +125,7 @@ The SDK is written in TypeScript, but fully compatible with both TypeScript and - [src](./src) - the complete SDK written in TypeScript. -- [lib](./lib) - the JavaScript version of the project (built from TypeScript). This should not be edited directly, as it will be overwritten with each release. +- [lib](./lib) - the JavaScript version of the project (built from TypeScript) that is published to npm. This should not be edited directly, as it will be overwritten with each release. - [examples](./examples) - examples & demonstrations. Contributions are welcome! - [test](./test) - automated end-to-end tests that run before every release, making real API calls. @@ -149,7 +150,7 @@ Here are the available REST clients and the corresponding API groups described i | :----------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------: | | [ **V5 API** ] | The new unified V5 APIs (successor to previously fragmented APIs for all API groups). | | [RestClientV5](src/rest-client-v5.ts) | Unified V5 all-in-one REST client for all [V5 REST APIs](https://bybit-exchange.github.io/docs/v5/intro) | -| [WebsocketClient](src/websocket-client.ts) | All WebSocket Events (Public & Private for all API categories) | +| [WebsocketClient](src/websocket-client.ts) | All WebSocket features (Public & Private consumers for all API categories & the WebSocket API) | ## REST API Usage @@ -181,7 +182,9 @@ const restClientOptions = { * Set to `true` to use Bybit's V5 demo trading: * https://bybit-exchange.github.io/docs/v5/demo * - * Note: to use demo trading, you should have `testnet` disabled + * Note: to use demo trading, you should have `testnet` disabled. + * + * You can find a detailed demoTrading example in the examples folder on GitHub. */ // demoTrading: true, @@ -227,6 +230,7 @@ const client = new RestClientV5({ key: API_KEY, secret: API_SECRET, // demoTrading: true, + // Optional: enable to try parsing rate limit values from responses // parseAPIRateLimits: true }, @@ -295,7 +299,7 @@ const wsConfig = { * Set to `true` to connect to Bybit's V5 demo trading: * https://bybit-exchange.github.io/docs/v5/demo * - * Only the "V5" "market" is supported here. + * Refer to the examples folder on GitHub for a more detailed demonstration. */ // demoTrading; true; @@ -392,17 +396,17 @@ ws.on('reconnected', (data) => { ## Websocket API - Sending orders via WebSockets -Bybit supports sending, amending and cancelling orders over a WebSocket connection. The [WebsocketClient](./src/WebsocketClient.ts) fully supports Bybit's WebSocket API. +Bybit supports sending, amending and cancelling orders over a WebSocket connection. The [WebsocketClient](./src/WebsocketClient.ts) fully supports Bybit's WebSocket API via the `sendWSAPIRequest(...)` method. Links for reference: - [Bybit WebSocket API Documentation](https://bybit-exchange.github.io/docs/v5/websocket/trade/guideline) - [WebSocket API Example Node.js/TypeScript/JavaScript](./examples/ws-api-promises.ts). -Note: as of January 2024, the demo trading environment does not support the WebSocket API. +Note: as of January 2025, the demo trading environment does not support the WebSocket API. There are two ways to use the WS API, depending on individual preference: - event-driven: - - send requests via `client.sendWSAPIRequest(wsKey, operation, params)`, fire and forget, don't use await + - send requests via `client.sendWSAPIRequest(wsKey, operation, params)`, fire and forget - handle async replies via event handlers on `client.on('exception', cb)` and `client.on('response', cb)` - See example for more details: [examples/ws-api-events.ts](./examples/ws-api-events.ts) - promise-driven: diff --git a/package-lock.json b/package-lock.json index 76fb1b3..1f99ff1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bybit-api", - "version": "4.0.0-beta.9", + "version": "4.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "bybit-api", - "version": "4.0.0-beta.9", + "version": "4.0.0", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/package.json b/package.json index ad975ca..a4af036 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bybit-api", - "version": "4.0.0-beta.9", + "version": "4.0.0", "description": "Complete & robust Node.js SDK for Bybit's REST APIs and WebSockets, with TypeScript & strong end to end tests.", "main": "lib/index.js", "types": "lib/index.d.ts", From 212d1712376cd63f859209cb60cbd56966a1d7c1 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 4 Mar 2025 11:10:18 +0000 Subject: [PATCH 53/60] chore(): readme update --- README.md | 2 +- examples/demo-trading.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 889809c..e6447bf 100644 --- a/README.md +++ b/README.md @@ -510,7 +510,7 @@ Important: do not subscribe to the same topics on both clients or you will recei ### Customise logging -Pass a custom logger (or mutate the imported DefaultLogger class) which supports the log methods `silly`, `debug`, `notice`, `info`, `warning` and `error`, or override methods from the default logger as desired, as in the example below: +Pass a custom logger (or mutate the imported DefaultLogger class) which supports the log methods `trace`, `info` and `error`, or override methods from the default logger as desired, as in the example below: ```javascript const { WebsocketClient, DefaultLogger } = require('bybit-api'); diff --git a/examples/demo-trading.ts b/examples/demo-trading.ts index b4002a0..951e22a 100644 --- a/examples/demo-trading.ts +++ b/examples/demo-trading.ts @@ -26,7 +26,7 @@ const restClient = new RestClientV5({ demoTrading: true, }); -// Optional, uncomment the "silly" override to log a lot more info about what the WS client is doing +// Optional, uncomment the "trace" override to log a lot more info about what the WS client is doing const customLogger = { ...DefaultLogger, // trace: (...params) => console.log('trace', ...params), From e65d7f0e0725b684330ee4e42bf1ed66b39814a2 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 4 Mar 2025 11:20:19 +0000 Subject: [PATCH 54/60] chore(): remove old comments --- src/util/websockets/WsStore.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/util/websockets/WsStore.ts b/src/util/websockets/WsStore.ts index 6b42b2b..de32820 100644 --- a/src/util/websockets/WsStore.ts +++ b/src/util/websockets/WsStore.ts @@ -380,10 +380,6 @@ export class WsStore< * @returns */ getMatchingTopic(key: WsKey, topic: TWSTopicSubscribeEventArgs) { - // if (typeof topic === 'string') { - // return this.getMatchingTopic(key, { channel: topic }); - // } - const allTopics = this.getTopics(key).values(); for (const storedTopic of allTopics) { if (isDeepObjectMatch(topic, storedTopic)) { @@ -393,13 +389,6 @@ export class WsStore< } addTopic(key: WsKey, topic: TWSTopicSubscribeEventArgs) { - // if (typeof topic === 'string') { - // return this.addTopic(key, { - // instType: 'sp', - // channel: topic, - // instId: 'default', - // }; - // } // Check for duplicate topic. If already tracked, don't store this one const existingTopic = this.getMatchingTopic(key, topic); if (existingTopic) { From 78e4025fe5a20b04b9809a51e963a967011fa59e Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 4 Mar 2025 11:22:16 +0000 Subject: [PATCH 55/60] chore(): update safeTerminateWs --- src/util/BaseWSClient.ts | 4 ++-- src/util/websockets/websocket-util.ts | 22 +++++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts index 24b7f76..c9832c9 100644 --- a/src/util/BaseWSClient.ts +++ b/src/util/BaseWSClient.ts @@ -509,7 +509,7 @@ export abstract class BaseWebsocketClient< const ws = this.getWs(wsKey); ws?.close(); if (force) { - safeTerminateWs(ws); + safeTerminateWs(ws, false); } } @@ -726,7 +726,7 @@ export abstract class BaseWebsocketClient< if (ws) { ws.close(); - safeTerminateWs(ws); + safeTerminateWs(ws, false); } if (!wasOpen) { diff --git a/src/util/websockets/websocket-util.ts b/src/util/websockets/websocket-util.ts index 2908d62..c4a4ee0 100644 --- a/src/util/websockets/websocket-util.ts +++ b/src/util/websockets/websocket-util.ts @@ -309,14 +309,26 @@ export const WS_ERROR_ENUM = { /** * #305: ws.terminate() is undefined in browsers. * This only works in node.js, not in browsers. - * Does nothing if `ws` is undefined. + * Does nothing if `ws` is undefined. Does nothing in browsers. */ -export function safeTerminateWs(ws?: WebSocket | unknown) { - // #305: ws.terminate() undefined in browsers - if (ws && typeof ws['terminate'] === 'function') { - ws.terminate(); +export function safeTerminateWs( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ws?: WebSocket | any, + fallbackToClose?: boolean, +): boolean { + if (!ws) { + return false; } + if (typeof ws['terminate'] === 'function') { + ws.terminate(); + return true; + } else if (fallbackToClose) { + ws.close(); + } + + return false; } + /** * WS API promises are stored using a primary key. This key is constructed using * properties found in every request & reply. From 177e04fff959ee87b3cf65f4ee92f7ba50f85fd0 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 4 Mar 2025 11:28:27 +0000 Subject: [PATCH 56/60] chore(): remove old comments --- src/util/BaseWSClient.ts | 2 -- src/websocket-client.ts | 9 +-------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts index c9832c9..ed01ed6 100644 --- a/src/util/BaseWSClient.ts +++ b/src/util/BaseWSClient.ts @@ -790,8 +790,6 @@ export abstract class BaseWebsocketClient< wsKey: TWSKey, operation: WsOperation, ): Promise[]> { - // console.log(new Date(), `called getWsSubscribeEventsForTopics()`, topics); - // console.trace(); if (!topics.length) { return []; } diff --git a/src/websocket-client.ts b/src/websocket-client.ts index d87bbd0..4f1b485 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -12,6 +12,7 @@ import { WSConnectedResult, WS_AUTH_ON_CONNECT_KEYS, WS_KEY_MAP, + WS_LOGGER_CATEGORY, WsTopicRequest, getMaxTopicsPerSubscribeEvent, getNormalisedTopicRequests, @@ -43,8 +44,6 @@ import { } from './types/websockets/ws-api'; import { SignAlgorithm, signMessage } from './util/webCryptoAPI'; -const WS_LOGGER_CATEGORY = { category: 'bybit-ws' }; - export class WebsocketClient extends BaseWebsocketClient< WsKey, WsRequestOperationBybit @@ -633,9 +632,6 @@ export class WebsocketClient extends BaseWebsocketClient< return true; } - // console.log('isWsPing?', { - // data: msg.data, - // }); return false; } @@ -659,9 +655,6 @@ export class WebsocketClient extends BaseWebsocketClient< return true; } - // console.log('isWsPong?', { - // data: msg.data, - // }); return false; } From cf61a444581b7b620dfe05234130b82ac3964891 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 4 Mar 2025 11:43:15 +0000 Subject: [PATCH 57/60] chore(): readme updates --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e6447bf..9738108 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,12 @@ Professional Node.js, JavaScript & TypeScript SDK for the Bybit REST APIs and We - [Related Projects](#related-projects) - [Documentation](#documentation) -## Structure & Usage -- [Structure](#structure) +## REST API Examples & Usage Guidance +- [Detailed Examples](#examples) - [API Clients](#api-clients) -- [REST API USAGE](#rest-api-usage) +- [REST API Usage](#rest-api-usage) -## WebSocket Integration +## WebSocket Integration & Examples - [WebSockets](#websockets) - [WebSocket Subscriptions - Consuming Events](#websocket-subscriptions---consuming-events) - [Websocket API - Sending Orders via WebSockets](#websocket-api---sending-orders-via-websockets) @@ -131,6 +131,8 @@ The SDK is written in TypeScript, but fully compatible with both TypeScript and --- +## Examples + Examples for using each client can be found in: - the [examples](./examples) folder. From bb3724113367d368eeadf4e2a72221b466d891d8 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 4 Mar 2025 11:43:40 +0000 Subject: [PATCH 58/60] chore(): readme update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9738108..19f4eea 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Professional Node.js, JavaScript & TypeScript SDK for the Bybit REST APIs and We - [Related Projects](#related-projects) - [Documentation](#documentation) -## REST API Examples & Usage Guidance +## REST API Examples - [Detailed Examples](#examples) - [API Clients](#api-clients) - [REST API Usage](#rest-api-usage) From 789067b049bd380723058f9ac13712f61dfc79d9 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 4 Mar 2025 11:44:35 +0000 Subject: [PATCH 59/60] chore(): readme update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 19f4eea..711ed71 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,9 @@ Professional Node.js, JavaScript & TypeScript SDK for the Bybit REST APIs and We - [Issues & Discussion](#issues--discussion) - [Related Projects](#related-projects) - [Documentation](#documentation) +- [Detailed Examples](#examples) ## REST API Examples -- [Detailed Examples](#examples) - [API Clients](#api-clients) - [REST API Usage](#rest-api-usage) From e1b51ee75003141337c26801004d4adefa242ef5 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 4 Mar 2025 11:45:03 +0000 Subject: [PATCH 60/60] chore(): readme update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 711ed71..2ed0d59 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Professional Node.js, JavaScript & TypeScript SDK for the Bybit REST APIs and We - [Issues & Discussion](#issues--discussion) - [Related Projects](#related-projects) - [Documentation](#documentation) -- [Detailed Examples](#examples) +- [Examples](#examples) ## REST API Examples - [API Clients](#api-clients)