diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index fa8f557b..6a43a9ae 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -35,11 +35,9 @@ jobs: run: npm run static-test - name: Live Tests (TS ESM) run: npm run ts-test-live - - name: Ws Live Tests (spot) - run: npm run ws-tests-spot - - name: Ws Live Tests (futures) - run: npm run ws-tests-futures - name: CJS test run: npm run test-cjs - name: Package test - run: npm run package-test \ No newline at end of file + run: npm run package-test + - name: Ws Live Tests + run: npm run ws-live-tests diff --git a/package.json b/package.json index f710d458..49574441 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "ws-tests": "mocha ./tests/binance-class-ws.test.ts", "ws-tests-spot": "mocha ./tests/binance-ws-spot.test.ts --exit", "ws-tests-futures": "mocha ./tests/binance-ws-futures.test.ts --exit", + "ws-api-userdata-tests": "mocha ./tests/binance-ws-api-userdata.test.ts --exit", + "ws-live-tests": "mocha ./tests/binance-ws-spot.test.ts ./tests/binance-ws-futures.test.ts ./tests/binance-ws-api-userdata.test.ts --exit", "test-debug": "mocha --inspect-brk", "lint": "eslint src/", "cover": "istanbul cover _mocha --report lcovonly", diff --git a/src/node-binance-api.ts b/src/node-binance-api.ts index e2beed89..896f56dc 100644 --- a/src/node-binance-api.ts +++ b/src/node-binance-api.ts @@ -57,6 +57,8 @@ export default class Binance { combineStream = `wss://stream.binance.${this.domain}:9443/stream?streams=`; combineStreamTest = `wss://stream.testnet.binance.vision/stream?streams=`; combineStreamDemo = `wss://demo-stream.binance.com/stream?streams=`; + wsApi = `wss://ws-api.binance.${this.domain}:443/ws-api/v3`; + wsApiTest = `wss://ws-api.testnet.binance.vision/ws-api/v3`; verbose = false; @@ -93,6 +95,8 @@ export default class Binance { headers: Dict = {}; subscriptions: Dict = {}; futuresSubscriptions: Dict = {}; + wsApiConnections: Dict = {}; // WebSocket API connections + wsApiPendingRequests: Dict = {}; // Pending JSON-RPC requests futuresInfo: Dict = {}; futuresMeta: Dict = {}; futuresTicks: Dict = {}; @@ -119,7 +123,7 @@ export default class Binance { userDeliveryData: this.userDeliveryData.bind(this), subscribeCombined: this.subscribeCombined.bind(this), subscribe: this.subscribe.bind(this), - subscriptions: () => this.getSubscriptions.bind(this), + subscriptions: () => this.subscriptions, terminate: this.terminate.bind(this), depth: this.depthStream.bind(this), depthCache: this.depthCacheStream.bind(this), @@ -147,8 +151,8 @@ export default class Binance { deliveryBookTicker: this.deliveryBookTickerStream.bind(this), deliveryChart: this.deliveryChart.bind(this), deliveryLiquidation: this.deliveryLiquidationStream.bind(this), - futuresSubcriptions: () => this.getFuturesSubscriptions.bind(this), - deliverySubcriptions: () => this.getDeliverySubscriptions.bind(this), + futuresSubcriptions: () => this.futuresSubscriptions, + deliverySubcriptions: () => this.deliverySubscriptions, futuresTerminate: this.futuresTerminate.bind(this), deliveryTerminate: this.deliveryTerminate.bind(this), }; @@ -279,6 +283,11 @@ export default class Binance { return this.stream; } + getWsApiUrl() { + if (this.Options.test) return this.wsApiTest; + return this.wsApi; + } + getDStreamSingleUrl() { if (this.Options.demo) return this.dstreamSingleDemo; if (this.Options.test) return this.dstreamSingleTest; @@ -404,23 +413,13 @@ export default class Binance { } async proxyRequest(opt: any) { - // const req = request(this.addProxy(opt), this.reqHandler(cb)).on('error', (err) => { cb(err, {}) }); - // family: opt.family, - // timeout: opt.timeout, - const urlBody = new URLSearchParams(opt.form); const reqOptions: Dict = { method: opt.method, headers: opt.headers, - // body: urlBody - // body: (opt.form) }; if (opt.method !== 'GET') { reqOptions.body = urlBody; - } else { - if (opt.qs) { - // opt.url += '?' + this.makeQueryString(opt.qs); - } } if (this.Options.verbose) { this.Options.log('HTTP Request:', opt.method, opt.url, reqOptions); @@ -442,21 +441,36 @@ export default class Binance { opt.url = urlProxy + opt.url; } + // Apply timeout via AbortController + const timeout = opt.timeout || this.Options.recvWindow || 30000; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + reqOptions.signal = controller.signal; + let fetchImplementation = fetch; // require node-fetch if (reqOptions.agent) { fetchImplementation = nodeFetch; } - const response = await fetchImplementation(opt.url, reqOptions); + try { + const response = await fetchImplementation(opt.url, reqOptions); + clearTimeout(timeoutId); - await this.reqHandler(response); - const json = await response.json(); + await this.reqHandler(response); + const json = await response.json(); - if (this.Options.verbose) { - this.Options.log('HTTP Response:', json); + if (this.Options.verbose) { + this.Options.log('HTTP Response:', json); + } + return json; + } catch (error) { + clearTimeout(timeoutId); + if (error.name === 'AbortError') { + throw new Error(`Request timeout: ${opt.method} ${opt.url} (${timeout}ms)`); + } + throw error; } - return json; } reqObj(url: string, data: Dict = {}, method: HttpMethod = 'GET', key?: string) { @@ -1322,7 +1336,7 @@ export default class Binance { handleSocketOpen(wsBind, opened_callback: Callback) { wsBind.isAlive = true; if (Object.keys(this.subscriptions).length === 0) { - this.socketHeartbeatInterval = setInterval(this.socketHeartbeat, this.heartBeatInterval); + this.socketHeartbeatInterval = setInterval(this.socketHeartbeat.bind(this), this.heartBeatInterval); } this.subscriptions[wsBind.url] = wsBind; if (typeof opened_callback === 'function') opened_callback(wsBind.url); @@ -1504,6 +1518,155 @@ export default class Binance { ws.terminate(); } + /** + * Connect to WebSocket API for bidirectional JSON-RPC communication + * @param {string} connectionId - unique identifier for this connection + * @param {function} messageHandler - callback for handling incoming messages/events + * @param {function} reconnect - reconnect callback + * @return {WebSocket} - WebSocket connection + */ + connectWsApi(connectionId: string, messageHandler: Callback, reconnect?: Callback) { + const httpsproxy = this.getHttpsProxy(); + let socksproxy = this.getSocksProxy(); + let ws: WebSocket = undefined; + + if (socksproxy) { + socksproxy = this.proxyReplacewithIp(socksproxy); + if (this.Options.verbose) this.Options.log('WebSocket API: using socks proxy server ' + socksproxy); + const agent = new SocksProxyAgent({ + protocol: this.parseProxy(socksproxy)[0], + host: this.parseProxy(socksproxy)[1], + port: this.parseProxy(socksproxy)[2] + }); + ws = new WebSocket(this.getWsApiUrl(), { agent: agent }); + } else if (httpsproxy) { + const config = url.parse(httpsproxy); + const agent = new HttpsProxyAgent(config); + if (this.Options.verbose) this.Options.log('WebSocket API: using proxy server ' + agent); + ws = new WebSocket(this.getWsApiUrl(), { agent: agent }); + } else { + ws = new WebSocket(this.getWsApiUrl()); + } + + (ws as any).reconnect = this.Options.reconnect; + (ws as any).connectionId = connectionId; + (ws as any).isAlive = false; + + ws.on('open', () => { + if (this.Options.verbose) this.Options.log('WebSocket API: Connected to ' + this.getWsApiUrl()); + this.handleSocketOpen(ws, null); + }); + ws.on('pong', this.handleSocketHeartbeat.bind(this, ws)); + ws.on('error', this.handleSocketError.bind(this, ws)); + ws.on('close', this.handleSocketClose.bind(this, ws, reconnect)); + ws.on('message', data => { + try { + if (this.Options.verbose) this.Options.log('WebSocket API data:', data); + const message = JSONbig.parse(data as any); + + // Handle JSON-RPC responses + if (message.id && this.wsApiPendingRequests[message.id]) { + const pending = this.wsApiPendingRequests[message.id]; + + if (message.status === 200) { + pending.resolve(message.result); + } else { + pending.reject(new Error(`WebSocket API error: ${message.error?.msg || 'Unknown error'}`)); + } + } + // Handle events (messages without 'id' or with 'subscriptionId') + else if (message.subscriptionId !== undefined || message.event) { + messageHandler(message); + } + } catch (error) { + this.Options.log('WebSocket API: Parse error: ' + error.message); + } + }); + + this.wsApiConnections[connectionId] = ws; + return ws; + } + + /** + * Send a JSON-RPC request on the WebSocket API connection + * @param {string} connectionId - connection identifier + * @param {string} method - JSON-RPC method name + * @param {object} params - method parameters + * @return {Promise} - resolves with the result or rejects with error + */ + sendWsApiRequest(connectionId: string, method: string, params: any = {}): Promise { + return new Promise((resolve, reject) => { + const ws = this.wsApiConnections[connectionId]; + if (!ws || ws.readyState !== WebSocket.OPEN) { + reject(new Error('WebSocket API connection not open')); + return; + } + + const requestId = this.generateRequestId(); + const request = { + id: requestId, + method: method, + params: params + }; + + const settle = (fn: Function, value: any) => { + const pending = this.wsApiPendingRequests[requestId]; + if (!pending) return; // already settled + clearTimeout(pending.timer); + delete this.wsApiPendingRequests[requestId]; + fn(value); + }; + + const timer = setTimeout(() => { + settle(reject, new Error('WebSocket API request timeout')); + }, 30000); + + this.wsApiPendingRequests[requestId] = { resolve: (v: any) => settle(resolve, v), reject: (e: any) => settle(reject, e), timer, connectionId }; + + if (this.Options.verbose) { + this.Options.log('WebSocket API: Sending request:', JSON.stringify(request)); + } + + ws.send(JSON.stringify(request), (error) => { + if (error) { + settle(reject, error); + } + }); + }); + } + + /** + * Generate a unique request ID for JSON-RPC requests + * @return {string} - unique request ID + */ + generateRequestId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; + } + + /** + * Terminate a WebSocket API connection + * @param {string} connectionId - connection identifier + * @param {boolean} reconnect - whether to reconnect + * @return {undefined} + */ + terminateWsApi(connectionId: string, reconnect = false) { + if (this.Options.verbose) this.Options.log('WebSocket API terminating:', connectionId); + const ws = this.wsApiConnections[connectionId]; + if (!ws) return; + ws.removeAllListeners('message'); + ws.reconnect = reconnect; + ws.terminate(); + delete this.wsApiConnections[connectionId]; + + // Reject all pending requests for this connection + for (const requestId in this.wsApiPendingRequests) { + const pending = this.wsApiPendingRequests[requestId]; + if (pending.connectionId === connectionId) { + pending.reject(new Error('WebSocket API connection terminated')); + } + } + } + /** * Futures heartbeat code with a shared single interval tick * @return {undefined} @@ -1531,7 +1694,7 @@ export default class Binance { handleFuturesSocketOpen(wsBind: any, openCallback: Callback) { wsBind.isAlive = true; if (Object.keys(this.futuresSubscriptions).length === 0) { - this.socketHeartbeatInterval = setInterval(this.futuresSocketHeartbeat, this.heartBeatInterval); + this.socketHeartbeatInterval = setInterval(this.futuresSocketHeartbeat.bind(this), this.heartBeatInterval); } this.futuresSubscriptions[wsBind.url] = wsBind; if (typeof openCallback === 'function') openCallback(wsBind.url); @@ -2245,7 +2408,7 @@ export default class Binance { handleDeliverySocketOpen(wsBind, openCallback: Callback) { this.isAlive = true; if (Object.keys(this.deliverySubscriptions).length === 0) { - this.socketHeartbeatInterval = setInterval(this.deliverySocketHeartbeat, 30000); + this.socketHeartbeatInterval = setInterval(this.deliverySocketHeartbeat.bind(this), 30000); } this.deliverySubscriptions[wsBind.url] = this; if (typeof openCallback === 'function') openCallback(wsBind.url); @@ -2788,16 +2951,33 @@ export default class Binance { * @return {undefined} */ userDataHandler(data: any) { - const type = data.e; - this.Options.all_updates_callback(data); + let eventData = data; + if (data.subscriptionId !== undefined && data.event) { + eventData = data.event; + } + + const type = eventData.e; + + // Handle event stream termination + if (type === 'eventStreamTerminated') { + this.Options.log('User Data Stream terminated at ' + eventData.E); + if (this.Options.all_updates_callback) this.Options.all_updates_callback(eventData); + return; + } + + if (this.Options.all_updates_callback) this.Options.all_updates_callback(eventData); + if (type === 'outboundAccountInfo') { // XXX: Deprecated in 2020-09-08 } else if (type === 'executionReport') { - if (this.Options.execution_callback) this.Options.execution_callback(data); + if (this.Options.execution_callback) this.Options.execution_callback(eventData); } else if (type === 'listStatus') { - if (this.Options.list_status_callback) this.Options.list_status_callback(data); + if (this.Options.list_status_callback) this.Options.list_status_callback(eventData); } else if (type === 'outboundAccountPosition' || type === 'balanceUpdate') { - if (this.Options.balance_callback) this.Options.balance_callback(data); + if (this.Options.balance_callback) this.Options.balance_callback(eventData); + } else if (type === 'externalLockUpdate') { + // Handle external lock updates (e.g., when balance is locked for margin collateral) + if (this.Options.balance_callback) this.Options.balance_callback(eventData); } else { this.Options.log('Unexpected userData: ' + type); } @@ -2809,18 +2989,33 @@ export default class Binance { * @return {undefined} */ userMarginDataHandler(data: any) { - const type = data.e; + let eventData = data; + if (data.subscriptionId !== undefined && data.event) { + eventData = data.event; + } + + const type = eventData.e; + + // Handle event stream termination + if (type === 'eventStreamTerminated') { + this.Options.log('Margin Data Stream terminated at ' + eventData.E); + if (this.Options.margin_all_updates_callback) this.Options.margin_all_updates_callback(eventData); + return; + } - if (this.Options.margin_all_updates_callback) this.Options.all_updates_callback(data); + if (this.Options.margin_all_updates_callback) this.Options.margin_all_updates_callback(eventData); if (type === 'outboundAccountInfo') { // XXX: Deprecated in 2020-09-08 } else if (type === 'executionReport') { - if (this.Options.margin_execution_callback) this.Options.margin_execution_callback(data); + if (this.Options.margin_execution_callback) this.Options.margin_execution_callback(eventData); } else if (type === 'listStatus') { - if (this.Options.margin_list_status_callback) this.Options.margin_list_status_callback(data); + if (this.Options.margin_list_status_callback) this.Options.margin_list_status_callback(eventData); } else if (type === 'outboundAccountPosition' || type === 'balanceUpdate') { - this.Options.margin_balance_callback(data); + if (this.Options.margin_balance_callback) this.Options.margin_balance_callback(eventData); + } else if (type === 'externalLockUpdate') { + // Handle external lock updates (e.g., when balance is locked for margin collateral) + if (this.Options.margin_balance_callback) this.Options.margin_balance_callback(eventData); } } @@ -3663,6 +3858,54 @@ export default class Binance { return this.priceData(data); } + /** + * Gets the ticker price via WebSocket API (JSON-RPC) + * @param {string} symbol - single symbol + * @param {string[]} symbols - array of symbols + * @param {object} options - additional options (e.g. symbolStatus) + * @see https://developers.binance.com/docs/binance-spot-api-docs/web-socket-api/market-data-requests#symbol-price-ticker + * @return {promise} - resolves with ticker price data + */ + async tickerPrice(symbol?: string, symbols?: string[], options: Dict = {}): Promise { + if (symbol && symbols) { + throw new Error('Cannot specify both symbol and symbols parameters'); + } + + const connectionId = 'marketData'; + await this.ensureWsApiConnection(connectionId); + + const params: Dict = { ...options }; + if (symbol) params.symbol = symbol; + if (symbols) params.symbols = symbols; + + return this.sendWsApiRequest(connectionId, 'ticker.price', params); + } + + /** + * Ensures a WebSocket API connection is open for the given connectionId + * @param {string} connectionId - connection identifier + * @return {promise} - resolves when the connection is open + */ + private ensureWsApiConnection(connectionId: string): Promise { + return new Promise((resolve, reject) => { + const existing = this.wsApiConnections[connectionId]; + if (existing) { + if (existing.readyState === WebSocket.OPEN) { + resolve(); + return; + } + if (existing.readyState === WebSocket.CONNECTING) { + existing.once('open', () => resolve()); + existing.once('error', (err: Error) => reject(err)); + return; + } + } + const ws = this.connectWsApi(connectionId, () => {}, () => {}); + ws.once('open', () => resolve()); + ws.once('error', (err: Error) => reject(err)); + }); + } + /** * Gets the book tickers of given symbol(s) * @see https://developers.binance.com/docs/binance-spot-api-docs/testnet/rest-api/market-data-endpoints#symbol-order-book-ticker @@ -5819,26 +6062,42 @@ export default class Binance { */ userData(all_updates_callback?: Callback, balance_callback?: Callback, execution_callback?: Callback, subscribed_callback?: Callback, list_status_callback?: Callback) { const reconnect = () => { - if (this.Options.reconnect) this.userData(all_updates_callback, balance_callback, execution_callback, subscribed_callback); + if (this.Options.reconnect) this.userData(all_updates_callback, balance_callback, execution_callback, subscribed_callback, list_status_callback); }; - this.apiRequest(this.getSpotUrl() + 'v3/userDataStream', {}, 'POST').then((response: any) => { - this.Options.listenKey = response.listenKey; - const keepAlive = this.spotListenKeyKeepAlive; - const self = this; - setTimeout(async function userDataKeepAlive() { // keepalive - try { - await self.apiRequest(self.getSpotUrl() + 'v3/userDataStream?listenKey=' + self.Options.listenKey, {}, 'PUT'); - setTimeout(userDataKeepAlive, keepAlive); // 30 minute keepalive - } catch (error) { - setTimeout(userDataKeepAlive, 60000); // retry in 1 minute + + // Set up callbacks + this.Options.all_updates_callback = all_updates_callback; + this.Options.balance_callback = balance_callback; + this.Options.execution_callback = execution_callback ? execution_callback : balance_callback; + this.Options.list_status_callback = list_status_callback; + + // Connect to WebSocket API + const connectionId = 'userData'; + const ws = this.connectWsApi(connectionId, this.userDataHandler.bind(this), reconnect); + + ws.on('open', async () => { + try { + // Subscribe using userDataStream.subscribe.signature method + const timestamp = Date.now(); + const query = `apiKey=${this.APIKEY}×tamp=${timestamp}`; + const signature = this.generateSignature(query); + + const result = await this.sendWsApiRequest(connectionId, 'userDataStream.subscribe.signature', { + apiKey: this.APIKEY, + timestamp: timestamp, + signature: signature + }); + + this.Options.userDataSubscriptionId = result.subscriptionId; + if (this.Options.verbose) { + this.Options.log(`User Data Stream subscribed with subscriptionId: ${result.subscriptionId}`); } - }, keepAlive); // 30 minute keepalive - this.Options.all_updates_callback = all_updates_callback; - this.Options.balance_callback = balance_callback; - this.Options.execution_callback = execution_callback ? execution_callback : balance_callback;//This change is required to listen for Orders - this.Options.list_status_callback = list_status_callback; - const subscription = this.subscribe(this.Options.listenKey, this.userDataHandler.bind(this), reconnect) as any; - if (subscribed_callback) subscribed_callback(subscription.endpoint); + + if (subscribed_callback) subscribed_callback(connectionId); + } catch (error) { + this.Options.log('User Data Stream subscription error:', error.message); + if (reconnect) setTimeout(reconnect, 5000); + } }); } @@ -5851,30 +6110,95 @@ export default class Binance { * @return {undefined} */ userMarginData(all_updates_callback?: Callback, balance_callback?: Callback, execution_callback?: Callback, subscribed_callback?: Callback, list_status_callback?: Callback) { + const self = this; const reconnect = () => { - if (this.Options.reconnect) this.userMarginData(balance_callback, execution_callback, subscribed_callback); + if (this.Options.reconnect) this.userMarginData(all_updates_callback, balance_callback, execution_callback, subscribed_callback, list_status_callback); }; - this.apiRequest(this.sapi + 'v1/userDataStream', {}, 'POST').then((response: any) => { - this.Options.listenMarginKey = response.listenKey; - const url = this.sapi + 'v1/userDataStream?listenKey=' + this.Options.listenMarginKey; - const apiRequest = this.apiRequest; - const keepAlive = this.spotListenKeyKeepAlive; - setTimeout(async function userDataKeepAlive() { // keepalive + // Set up callbacks + this.Options.margin_all_updates_callback = all_updates_callback; + this.Options.margin_balance_callback = balance_callback; + this.Options.margin_execution_callback = execution_callback; + this.Options.margin_list_status_callback = list_status_callback; + + // Get listenToken from REST API + this.apiRequest(this.sapi + 'v1/userListenToken', {}, 'POST').then((response: any) => { + const listenToken = response.token; + const expirationTime = response.expirationTime; + this.Options.marginListenToken = listenToken; + this.Options.marginListenTokenExpiry = expirationTime; + + if (this.Options.verbose) { + this.Options.log(`Margin listenToken obtained, expires at: ${new Date(expirationTime).toISOString()}`); + } + + // Connect to WebSocket API + const connectionId = 'userMarginData'; + const ws = this.connectWsApi(connectionId, this.userMarginDataHandler.bind(this), reconnect); + + ws.on('open', async () => { try { - await apiRequest(url, {}, 'PUT'); - // if (err) setTimeout(userDataKeepAlive, 60000); // retry in 1 minute - setTimeout(userDataKeepAlive, keepAlive); // 30 minute keepalive + // Subscribe using userDataStream.subscribe.listenToken method + const result = await this.sendWsApiRequest(connectionId, 'userDataStream.subscribe.listenToken', { + listenToken: listenToken + }); + + this.Options.marginDataSubscriptionId = result.subscriptionId; + const subscriptionExpiry = result.expirationTime; + + if (this.Options.verbose) { + this.Options.log(`Margin Data Stream subscribed with subscriptionId: ${result.subscriptionId}`); + this.Options.log(`Subscription expires at: ${new Date(subscriptionExpiry).toISOString()}`); + } + + // Set up renewal before expiration (renew 5 minutes before expiry) + const renewalTime = subscriptionExpiry - Date.now() - (5 * 60 * 1000); + if (renewalTime > 0) { + setTimeout(async function renewSubscription() { + try { + // Get new listenToken + const renewResponse: any = await self.apiRequest(self.sapi + 'v1/userListenToken', {}, 'POST'); + const newListenToken = renewResponse.token; + const newExpirationTime = renewResponse.expirationTime; + + if (self.Options.verbose) { + self.Options.log(`New margin listenToken obtained, expires at: ${new Date(newExpirationTime).toISOString()}`); + } + + // Re-subscribe with new token + const renewResult = await self.sendWsApiRequest(connectionId, 'userDataStream.subscribe.listenToken', { + listenToken: newListenToken + }); + + self.Options.marginDataSubscriptionId = renewResult.subscriptionId; + const newSubscriptionExpiry = renewResult.expirationTime; + + if (self.Options.verbose) { + self.Options.log(`Margin Data Stream renewed with subscriptionId: ${renewResult.subscriptionId}`); + } + + // Schedule next renewal + const nextRenewalTime = newSubscriptionExpiry - Date.now() - (5 * 60 * 1000); + if (nextRenewalTime > 0) { + setTimeout(renewSubscription, nextRenewalTime); + } + } catch (error) { + self.Options.log('Margin Data Stream renewal error:', error.message); + // Attempt to reconnect + if (reconnect) setTimeout(reconnect, 5000); + } + }, renewalTime); + } + + if (subscribed_callback) subscribed_callback(connectionId); } catch (error) { - setTimeout(userDataKeepAlive, 60000); // retry in 1 minute + this.Options.log('Margin Data Stream subscription error:', error.message); + if (reconnect) setTimeout(reconnect, 5000); } - }, keepAlive); // 30 minute keepalive - this.Options.margin_all_updates_callback = all_updates_callback; - this.Options.margin_balance_callback = balance_callback; - this.Options.margin_execution_callback = execution_callback; - this.Options.margin_list_status_callback = list_status_callback; - const subscription = this.subscribe(this.Options.listenMarginKey, this.userMarginDataHandler.bind(this), reconnect) as any; - if (subscribed_callback) subscribed_callback(subscription.endpoint); + }); + }).catch((error: any) => { + this.Options.log('Failed to obtain margin listenToken:', error.message); + if (reconnect) setTimeout(reconnect, 5000); }); } @@ -6146,11 +6470,7 @@ export default class Binance { return symbol.toLowerCase() + `@depth@100ms`; }); const mapLimit = this.mapLimit.bind(this); - subscription = this.subscribeCombined(streams, handleDepthStreamData, reconnect, function () { - // async.mapLimit(symbols, 50, getSymbolDepthSnapshot, (err, results) => { - // if (err) throw err; - // results.forEach(updateSymbolDepthCache); - // }); + subscription = this.subscribeCombined(streams, handleDepthStreamData, reconnect, () => { mapLimit(symbols, 50, getSymbolDepthSnapshot) .then(results => { results.forEach(updateSymbolDepthCache); @@ -6164,11 +6484,7 @@ export default class Binance { const symbol = symbols; symbolDepthInit(symbol); const mapLimit = this.mapLimit.bind(this); - subscription = this.subscribe(symbol.toLowerCase() + `@depth@100ms`, handleDepthStreamData, reconnect, function () { - // async.mapLimit([symbol], 1, getSymbolDepthSnapshot, (err, results) => { - // if (err) throw err; - // results.forEach(updateSymbolDepthCache); - // }); + subscription = this.subscribe(symbol.toLowerCase() + `@depth@100ms`, handleDepthStreamData, reconnect, () => { mapLimit([symbol], 1, getSymbolDepthSnapshot) .then(results => { results.forEach(updateSymbolDepthCache); @@ -6176,7 +6492,6 @@ export default class Binance { .catch(err => { throw err; }); - }); assignEndpointIdToContext(symbol, subscription.endpoint); } diff --git a/tests/binance-ws-api-ticker.test.ts b/tests/binance-ws-api-ticker.test.ts new file mode 100644 index 00000000..0e247771 --- /dev/null +++ b/tests/binance-ws-api-ticker.test.ts @@ -0,0 +1,280 @@ +import Binance from '../src/node-binance-api'; +import { assert } from 'chai'; + +const WARN_SHOULD_BE_OBJ = 'should be an object'; +const WARN_SHOULD_BE_NOT_NULL = 'should not be null'; +const WARN_SHOULD_HAVE_KEY = 'should have key '; +const WARN_SHOULD_BE_TYPE = 'should be a '; +const TIMEOUT = 30000; + +const binance = new Binance().options({ + APIKEY: 'X4BHNSimXOK6RKs2FcKqExquJtHjMxz5hWqF0BBeVnfa5bKFMk7X0wtkfEz0cPrJ', + APISECRET: 'x8gLihunpNq0d46F2q0TWJmeCDahX5LMXSlv3lSFNbMI3rujSOpTDKdhbcmPSf2i', + test: true, + verbose: false, + httpsProxy: 'http://188.245.226.105:8911' +}); + +describe('WebSocket API Ticker Price', function () { + + describe('tickerPrice - Single Symbol', function () { + it('should fetch price for a single symbol', async function () { + this.timeout(TIMEOUT); + + const result = await binance.tickerPrice('BTCUSDT'); + + assert(result !== null, WARN_SHOULD_BE_NOT_NULL); + assert(typeof result === 'object', WARN_SHOULD_BE_OBJ); + assert(Object.prototype.hasOwnProperty.call(result, 'symbol'), WARN_SHOULD_HAVE_KEY + 'symbol'); + assert(Object.prototype.hasOwnProperty.call(result, 'price'), WARN_SHOULD_HAVE_KEY + 'price'); + assert(result.symbol === 'BTCUSDT', 'Symbol should be BTCUSDT'); + assert(typeof result.price === 'string', WARN_SHOULD_BE_TYPE + 'string'); + assert(parseFloat(result.price) > 0, 'Price should be positive'); + + console.log('Single symbol result:', result); + }); + + it('should fetch price for another symbol', async function () { + this.timeout(TIMEOUT); + + const result = await binance.tickerPrice('ETHUSDT'); + + assert(result !== null, WARN_SHOULD_BE_NOT_NULL); + assert(typeof result === 'object', WARN_SHOULD_BE_OBJ); + assert(result.symbol === 'ETHUSDT', 'Symbol should be ETHUSDT'); + assert(Object.prototype.hasOwnProperty.call(result, 'price'), WARN_SHOULD_HAVE_KEY + 'price'); + assert(parseFloat(result.price) > 0, 'Price should be positive'); + + console.log('ETHUSDT price:', result.price); + }); + }); + + describe('tickerPrice - Multiple Symbols', function () { + it('should fetch prices for multiple symbols', async function () { + this.timeout(TIMEOUT); + + const symbols = ['BTCUSDT', 'ETHUSDT', 'BNBUSDT']; + const result = await binance.tickerPrice(undefined, symbols); + + assert(result !== null, WARN_SHOULD_BE_NOT_NULL); + assert(Array.isArray(result), 'Result should be an array'); + assert(result.length === symbols.length, `Should return ${symbols.length} prices`); + + result.forEach((ticker: any, index: number) => { + assert(typeof ticker === 'object', WARN_SHOULD_BE_OBJ); + assert(Object.prototype.hasOwnProperty.call(ticker, 'symbol'), WARN_SHOULD_HAVE_KEY + 'symbol'); + assert(Object.prototype.hasOwnProperty.call(ticker, 'price'), WARN_SHOULD_HAVE_KEY + 'price'); + assert(symbols.includes(ticker.symbol), `Symbol ${ticker.symbol} should be in requested list`); + assert(typeof ticker.price === 'string', WARN_SHOULD_BE_TYPE + 'string'); + assert(parseFloat(ticker.price) > 0, `Price for ${ticker.symbol} should be positive`); + }); + + console.log('Multiple symbols result:', result); + }); + + it('should fetch prices for 5 symbols', async function () { + this.timeout(TIMEOUT); + + const symbols = ['BTCUSDT', 'ETHUSDT', 'BNBUSDT', 'ADAUSDT', 'DOGEUSDT']; + const result = await binance.tickerPrice(undefined, symbols); + + assert(Array.isArray(result), 'Result should be an array'); + assert(result.length === symbols.length, `Should return ${symbols.length} prices`); + + const returnedSymbols = result.map((ticker: any) => ticker.symbol); + symbols.forEach(symbol => { + assert(returnedSymbols.includes(symbol), `Should include ${symbol}`); + }); + + console.log(`Fetched prices for ${result.length} symbols`); + }); + }); + + describe('tickerPrice - All Symbols', function () { + it('should fetch prices for all symbols when no parameters provided', async function () { + this.timeout(TIMEOUT); + + const result = await binance.tickerPrice(); + + assert(result !== null, WARN_SHOULD_BE_NOT_NULL); + assert(Array.isArray(result), 'Result should be an array'); + assert(result.length > 0, 'Should return at least one symbol'); + + // Check first few results + result.slice(0, 5).forEach((ticker: any) => { + assert(typeof ticker === 'object', WARN_SHOULD_BE_OBJ); + assert(Object.prototype.hasOwnProperty.call(ticker, 'symbol'), WARN_SHOULD_HAVE_KEY + 'symbol'); + assert(Object.prototype.hasOwnProperty.call(ticker, 'price'), WARN_SHOULD_HAVE_KEY + 'price'); + assert(typeof ticker.symbol === 'string', 'Symbol should be string'); + assert(typeof ticker.price === 'string', 'Price should be string'); + }); + + console.log(`Fetched prices for ${result.length} total symbols`); + }); + }); + + describe('tickerPrice - With Symbol Status Filter', function () { + it('should filter by TRADING status', async function () { + this.timeout(TIMEOUT); + + const result = await binance.tickerPrice('BTCUSDT', undefined, { symbolStatus: 'TRADING' }); + + assert(result !== null, WARN_SHOULD_BE_NOT_NULL); + assert(typeof result === 'object', WARN_SHOULD_BE_OBJ); + assert(result.symbol === 'BTCUSDT', 'Symbol should be BTCUSDT'); + assert(Object.prototype.hasOwnProperty.call(result, 'price'), WARN_SHOULD_HAVE_KEY + 'price'); + + console.log('Filtered result (TRADING):', result); + }); + + it('should filter multiple symbols by status', async function () { + this.timeout(TIMEOUT); + + const symbols = ['BTCUSDT', 'ETHUSDT']; + const result = await binance.tickerPrice(undefined, symbols, { symbolStatus: 'TRADING' }); + + assert(Array.isArray(result), 'Result should be an array'); + // Result may be filtered, so length could be <= symbols.length + assert(result.length <= symbols.length, 'Result should not exceed requested symbols'); + + console.log(`Filtered ${result.length} trading symbols from ${symbols.length} requested`); + }); + }); + + describe('tickerPrice - Error Handling', function () { + it('should reject when both symbol and symbols are provided', async function () { + this.timeout(TIMEOUT); + + try { + await binance.tickerPrice('BTCUSDT', ['ETHUSDT']); + assert.fail('Should have thrown an error'); + } catch (error: any) { + assert(error.message.includes('Cannot specify both'), 'Should indicate parameter conflict'); + } + }); + + it('should handle invalid symbol gracefully', async function () { + this.timeout(TIMEOUT); + + try { + await binance.tickerPrice('INVALIDSYMBOL123'); + // May succeed or fail depending on Binance's handling + // If it succeeds, it might return an empty result or error in result + } catch (error: any) { + // Expected to fail with invalid symbol + assert(error !== null, 'Should have error information'); + } + }); + }); + + describe('tickerPrice - Response Structure', function () { + it('should include rate limit information', async function () { + this.timeout(TIMEOUT); + + // Note: Rate limit info is in the JSON-RPC response but may not be returned by our method + // This test documents the expected structure + + const result = await binance.tickerPrice('BTCUSDT'); + + assert(result !== null, WARN_SHOULD_BE_NOT_NULL); + assert(Object.prototype.hasOwnProperty.call(result, 'symbol'), 'Should have symbol'); + assert(Object.prototype.hasOwnProperty.call(result, 'price'), 'Should have price'); + + // Note: rateLimits may be stripped by our implementation + // Full response from API includes: { symbol, price } + }); + + it('should return consistent structure for single and multiple symbols', async function () { + this.timeout(TIMEOUT); + + const singleResult = await binance.tickerPrice('BTCUSDT'); + const multiResult = await binance.tickerPrice(undefined, ['BTCUSDT']); + + assert(typeof singleResult === 'object', 'Single result should be object'); + assert(!Array.isArray(singleResult), 'Single result should not be array'); + + assert(Array.isArray(multiResult), 'Multi result should be array'); + assert(multiResult.length > 0, 'Multi result should have items'); + + // Compare structure + const multiItem = multiResult[0]; + assert(Object.keys(singleResult).length > 0, 'Single result should have keys'); + assert(Object.keys(multiItem).length > 0, 'Multi item should have keys'); + }); + }); + + describe('tickerPrice - WebSocket API Features', function () { + it('should use WebSocket API connection', async function () { + this.timeout(TIMEOUT); + + const startTime = Date.now(); + const result = await binance.tickerPrice('BTCUSDT'); + const endTime = Date.now(); + + const responseTime = endTime - startTime; + + assert(result !== null, WARN_SHOULD_BE_NOT_NULL); + console.log(`WebSocket API response time: ${responseTime}ms`); + + // WebSocket should be reasonably fast + assert(responseTime < 10000, 'Should respond within 10 seconds'); + }); + + it('should handle concurrent requests', async function () { + this.timeout(TIMEOUT); + + const promises = [ + binance.tickerPrice('BTCUSDT'), + binance.tickerPrice('ETHUSDT'), + binance.tickerPrice('BNBUSDT') + ]; + + const results = await Promise.all(promises); + + assert(results.length === 3, 'Should have 3 results'); + results.forEach((result, index) => { + assert(result !== null, `Result ${index} should not be null`); + assert(typeof result === 'object', `Result ${index} should be object`); + assert(Object.prototype.hasOwnProperty.call(result, 'price'), `Result ${index} should have price`); + }); + + console.log('Concurrent requests completed successfully'); + }); + }); + + describe('tickerPrice - Price Validation', function () { + it('should return valid numeric price strings', async function () { + this.timeout(TIMEOUT); + + const result = await binance.tickerPrice('BTCUSDT'); + + assert(result !== null, WARN_SHOULD_BE_NOT_NULL); + assert(typeof result.price === 'string', 'Price should be string'); + + const priceFloat = parseFloat(result.price); + assert(!isNaN(priceFloat), 'Price should be valid number'); + assert(isFinite(priceFloat), 'Price should be finite'); + assert(priceFloat > 0, 'Price should be positive'); + + // Check decimal format + assert(/^\d+(\.\d+)?$/.test(result.price), 'Price should match decimal pattern'); + + console.log(`BTCUSDT price: ${result.price} (${priceFloat})`); + }); + + it('should return prices with appropriate precision', async function () { + this.timeout(TIMEOUT); + + const symbols = ['BTCUSDT', 'ETHUSDT', 'BNBUSDT']; + const results = await binance.tickerPrice(undefined, symbols); + + results.forEach((ticker: any) => { + const decimalPlaces = (ticker.price.split('.')[1] || '').length; + assert(decimalPlaces >= 0, `Price for ${ticker.symbol} should have decimal places`); + assert(decimalPlaces <= 8, `Price for ${ticker.symbol} should not exceed 8 decimal places`); + + console.log(`${ticker.symbol}: ${ticker.price} (${decimalPlaces} decimals)`); + }); + }); + }); +}); diff --git a/tests/binance-ws-api-userdata.test.ts b/tests/binance-ws-api-userdata.test.ts new file mode 100644 index 00000000..9b674d3d --- /dev/null +++ b/tests/binance-ws-api-userdata.test.ts @@ -0,0 +1,685 @@ +import Binance from '../src/node-binance-api'; +import { assert } from 'chai'; +import WebSocket from 'ws'; + +const WARN_SHOULD_BE_OBJ = 'should be an object'; +const WARN_SHOULD_BE_NOT_NULL = 'should not be null'; +const WARN_SHOULD_HAVE_KEY = 'should have key '; +const WARN_SHOULD_BE_TYPE = 'should be a '; +const TIMEOUT = 60000; + +const binance = new Binance().options({ + APIKEY: 'X4BHNSimXOK6RKs2FcKqExquJtHjMxz5hWqF0BBeVnfa5bKFMk7X0wtkfEz0cPrJ', + APISECRET: 'x8gLihunpNq0d46F2q0TWJmeCDahX5LMXSlv3lSFNbMI3rujSOpTDKdhbcmPSf2i', + test: true, + verbose: false, + httpsProxy: 'http://188.245.226.105:8911' +}); + +const stopWsApiConnections = function (log = false) { + const connections = (binance as any).wsApiConnections; + for (let connectionId in connections) { + if (log) console.log('Terminated WebSocket API connection: ' + connectionId); + (binance as any).terminateWsApi(connectionId); + } +} + +describe('WebSocket API Infrastructure', function () { + + describe('generateRequestId', function () { + it('should generate unique request IDs', function () { + const id1 = (binance as any).generateRequestId(); + const id2 = (binance as any).generateRequestId(); + + assert(typeof id1 === 'string', WARN_SHOULD_BE_TYPE + 'string'); + assert(typeof id2 === 'string', WARN_SHOULD_BE_TYPE + 'string'); + assert(id1 !== id2, 'IDs should be unique'); + assert(id1.length > 0, 'ID should not be empty'); + }); + }); + + describe('getWsApiUrl', function () { + it('should return testnet URL when test mode is enabled', function () { + const url = (binance as any).getWsApiUrl(); + assert(typeof url === 'string', WARN_SHOULD_BE_TYPE + 'string'); + assert(url.includes('testnet'), 'should include testnet'); + assert(url.includes('ws-api'), 'should include ws-api'); + assert(url.includes('/ws-api/v3'), 'should include API version'); + }); + + it('should return production URL when test mode is disabled', function () { + const prodBinance = new Binance().options({ + APIKEY: 'test', + APISECRET: 'test', + test: false + }); + const url = (prodBinance as any).getWsApiUrl(); + assert(typeof url === 'string', WARN_SHOULD_BE_TYPE + 'string'); + assert(!url.includes('testnet'), 'should not include testnet'); + assert(url.includes('ws-api.binance.com'), 'should be production URL'); + }); + }); + + describe('connectWsApi', function () { + it('should create WebSocket API connection', function (done) { + this.timeout(TIMEOUT); + + const connectionId = 'test-connection'; + const ws = (binance as any).connectWsApi(connectionId, (data: any) => { + // Message handler + }, () => { + // Reconnect handler + }); + + ws.on('open', () => { + assert(ws !== null, WARN_SHOULD_BE_NOT_NULL); + assert(ws.readyState === WebSocket.OPEN, 'WebSocket should be open'); + assert((ws as any).connectionId === connectionId, 'Connection ID should match'); + + stopWsApiConnections(true); + done(); + }); + + ws.on('error', (error: Error) => { + stopWsApiConnections(); + done(error); + }); + }); + }); +}); + +describe('User Data Handler - Event Format Support', function () { + + describe('userDataHandler - Old Event Format', function () { + it('should handle old format outboundAccountPosition event', function () { + let capturedEvent: any = null; + + (binance as any).Options.all_updates_callback = (data: any) => { + capturedEvent = data; + }; + + const oldFormatEvent = { + e: 'outboundAccountPosition', + E: 1564034571105, + u: 1564034571073, + B: [ + { a: 'ETH', f: '10000.000000', l: '0.000000' } + ] + }; + + (binance as any).userDataHandler(oldFormatEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'outboundAccountPosition', 'Event type should match'); + assert(capturedEvent.E === 1564034571105, 'Event time should match'); + assert(Array.isArray(capturedEvent.B), 'Balances should be an array'); + }); + + it('should handle old format executionReport event', function () { + let capturedEvent: any = null; + + (binance as any).Options.execution_callback = (data: any) => { + capturedEvent = data; + }; + + const oldFormatEvent = { + e: 'executionReport', + E: 1499405658658, + s: 'ETHBTC', + c: 'mUvoqJxFIILMdfAW5iGSOW', + S: 'BUY', + o: 'LIMIT', + x: 'NEW', + X: 'NEW' + }; + + (binance as any).userDataHandler(oldFormatEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'executionReport', 'Event type should match'); + assert(capturedEvent.s === 'ETHBTC', 'Symbol should match'); + }); + }); + + describe('userDataHandler - New Event Format', function () { + it('should handle new format outboundAccountPosition event', function () { + let capturedEvent: any = null; + + (binance as any).Options.all_updates_callback = (data: any) => { + capturedEvent = data; + }; + + const newFormatEvent = { + subscriptionId: 0, + event: { + e: 'outboundAccountPosition', + E: 1564034571105, + u: 1564034571073, + B: [ + { a: 'ETH', f: '10000.000000', l: '0.000000' } + ] + } + }; + + (binance as any).userDataHandler(newFormatEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'outboundAccountPosition', 'Event type should match'); + assert(capturedEvent.E === 1564034571105, 'Event time should match'); + assert(Array.isArray(capturedEvent.B), 'Balances should be an array'); + }); + + it('should handle new format executionReport event', function () { + let capturedEvent: any = null; + + (binance as any).Options.execution_callback = (data: any) => { + capturedEvent = data; + }; + + const newFormatEvent = { + subscriptionId: 1, + event: { + e: 'executionReport', + E: 1499405658658, + s: 'ETHBTC', + c: 'mUvoqJxFIILMdfAW5iGSOW', + S: 'BUY', + o: 'LIMIT', + x: 'NEW', + X: 'NEW' + } + }; + + (binance as any).userDataHandler(newFormatEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'executionReport', 'Event type should match'); + assert(capturedEvent.s === 'ETHBTC', 'Symbol should match'); + }); + + it('should handle eventStreamTerminated event', function () { + let capturedEvent: any = null; + let loggedMessage = ''; + + (binance as any).Options.all_updates_callback = (data: any) => { + capturedEvent = data; + }; + + const originalLog = (binance as any).Options.log; + (binance as any).Options.log = (msg: string) => { + loggedMessage = msg; + }; + + const terminatedEvent = { + subscriptionId: 0, + event: { + e: 'eventStreamTerminated', + E: 1728973001334 + } + }; + + (binance as any).userDataHandler(terminatedEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'eventStreamTerminated', 'Event type should match'); + assert(loggedMessage.includes('terminated'), 'Should log termination'); + + (binance as any).Options.log = originalLog; + }); + + it('should handle externalLockUpdate event', function () { + let capturedEvent: any = null; + + (binance as any).Options.balance_callback = (data: any) => { + capturedEvent = data; + }; + + const lockUpdateEvent = { + subscriptionId: 0, + event: { + e: 'externalLockUpdate', + E: 1581557507324, + a: 'NEO', + d: '10.00000000', + T: 1581557507268 + } + }; + + (binance as any).userDataHandler(lockUpdateEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'externalLockUpdate', 'Event type should match'); + assert(capturedEvent.a === 'NEO', 'Asset should match'); + assert(capturedEvent.d === '10.00000000', 'Delta should match'); + }); + + it('should handle balanceUpdate event', function () { + let capturedEvent: any = null; + + (binance as any).Options.balance_callback = (data: any) => { + capturedEvent = data; + }; + + const balanceUpdateEvent = { + subscriptionId: 0, + event: { + e: 'balanceUpdate', + E: 1573200697110, + a: 'BTC', + d: '100.00000000', + T: 1573200697068 + } + }; + + (binance as any).userDataHandler(balanceUpdateEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'balanceUpdate', 'Event type should match'); + assert(capturedEvent.a === 'BTC', 'Asset should match'); + }); + + it('should handle listStatus event', function () { + let capturedEvent: any = null; + + (binance as any).Options.list_status_callback = (data: any) => { + capturedEvent = data; + }; + + const listStatusEvent = { + subscriptionId: 0, + event: { + e: 'listStatus', + E: 1564035303637, + s: 'ETHBTC', + g: 2, + c: 'OCO', + l: 'EXEC_STARTED', + L: 'EXECUTING' + } + }; + + (binance as any).userDataHandler(listStatusEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'listStatus', 'Event type should match'); + assert(capturedEvent.s === 'ETHBTC', 'Symbol should match'); + }); + }); +}); + +/* Skip margin in CI as does not support testnet +describe('Margin Data Handler - Event Format Support', function () { + + describe('userMarginDataHandler - Old Event Format', function () { + it('should handle old format margin events', function () { + let capturedEvent: any = null; + + (binance as any).Options.margin_all_updates_callback = (data: any) => { + capturedEvent = data; + }; + + const oldFormatEvent = { + e: 'outboundAccountPosition', + E: 1564034571105, + u: 1564034571073, + B: [ + { a: 'BTC', f: '1.00000000', l: '0.50000000' } + ] + }; + + (binance as any).userMarginDataHandler(oldFormatEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'outboundAccountPosition', 'Event type should match'); + }); + }); + + describe('userMarginDataHandler - New Event Format', function () { + it('should handle new format margin events', function () { + let capturedEvent: any = null; + + (binance as any).Options.margin_all_updates_callback = (data: any) => { + capturedEvent = data; + }; + + const newFormatEvent = { + subscriptionId: 1, + event: { + e: 'outboundAccountPosition', + E: 1564034571105, + u: 1564034571073, + B: [ + { a: 'BTC', f: '1.00000000', l: '0.50000000' } + ] + } + }; + + (binance as any).userMarginDataHandler(newFormatEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'outboundAccountPosition', 'Event type should match'); + }); + + it('should handle margin eventStreamTerminated', function () { + let capturedEvent: any = null; + let loggedMessage = ''; + + (binance as any).Options.margin_all_updates_callback = (data: any) => { + capturedEvent = data; + }; + + const originalLog = (binance as any).Options.log; + (binance as any).Options.log = (msg: string) => { + loggedMessage = msg; + }; + + const terminatedEvent = { + subscriptionId: 1, + event: { + e: 'eventStreamTerminated', + E: 1728973001334 + } + }; + + (binance as any).userMarginDataHandler(terminatedEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'eventStreamTerminated', 'Event type should match'); + assert(loggedMessage.includes('Margin'), 'Should log margin termination'); + + (binance as any).Options.log = originalLog; + }); + + it('should handle margin executionReport', function () { + let capturedEvent: any = null; + + (binance as any).Options.margin_execution_callback = (data: any) => { + capturedEvent = data; + }; + + const executionEvent = { + subscriptionId: 1, + event: { + e: 'executionReport', + E: 1499405658658, + s: 'BTCUSDT', + c: 'marginOrder123', + S: 'SELL', + o: 'MARKET', + x: 'TRADE', + X: 'FILLED' + } + }; + + (binance as any).userMarginDataHandler(executionEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'executionReport', 'Event type should match'); + assert(capturedEvent.s === 'BTCUSDT', 'Symbol should match'); + }); + }); +}); +*/ + +describe('WebSocket API JSON-RPC', function () { + + describe('sendWsApiRequest', function () { + it('should reject when connection is not open', async function () { + try { + await (binance as any).sendWsApiRequest('non-existent', 'test.method', {}); + assert.fail('Should have thrown an error'); + } catch (error: any) { + assert(error.message.includes('not open'), 'Should indicate connection is not open'); + } + }); + + it('should timeout after 30 seconds', async function () { + this.timeout(35000); + + const connectionId = 'timeout-test'; + const ws = (binance as any).connectWsApi(connectionId, () => {}, () => {}); + + await new Promise((resolve) => { + ws.on('open', resolve); + }); + + // Override send to prevent actual sending + const originalSend = ws.send; + ws.send = (data: any, callback: any) => { + if (callback) callback(); // Call callback without error + }; + + try { + await (binance as any).sendWsApiRequest(connectionId, 'test.method', {}); + assert.fail('Should have timed out'); + } catch (error: any) { + assert(error.message.includes('timeout'), 'Should indicate timeout'); + } finally { + ws.send = originalSend; + stopWsApiConnections(); + } + }).timeout(35000); + }); +}); + +describe('WebSocket API Live Tests', function () { + + describe('userData WebSocket API Connection', function () { + it('should connect and subscribe to user data stream', function (done) { + this.timeout(TIMEOUT); + + let subscriptionReceived = false; + + binance.websockets.userData( + (data) => { + // All updates callback + console.log('User data event:', data); + }, + (balance) => { + // Balance callback + console.log('Balance update:', balance); + }, + (execution) => { + // Execution callback + console.log('Execution report:', execution); + }, + (endpoint) => { + // Subscribed callback + console.log('Subscribed to:', endpoint); + subscriptionReceived = true; + + assert(endpoint !== null, WARN_SHOULD_BE_NOT_NULL); + assert(typeof endpoint === 'string', WARN_SHOULD_BE_TYPE + 'string'); + + // Wait a bit then cleanup + setTimeout(() => { + stopWsApiConnections(true); + done(); + }, 5000); + }, + (listStatus) => { + // List status callback + console.log('List status:', listStatus); + } + ); + }); + + it('should receive execution and balance events when creating a market order', function (done) { + this.timeout(TIMEOUT); + + let executionReceived = false; + let balanceReceived = false; + let subscriptionReady = false; + + binance.websockets.userData( + (data) => { + // All updates callback + console.log('Event received:', data.e, data); + + // Check if we received both events + if (executionReceived && balanceReceived && subscriptionReady) { + console.log('✅ Both execution and balance events received!'); + setTimeout(() => { + stopWsApiConnections(true); + done(); + }, 2000); + } + }, + (balance) => { + // Balance callback + console.log('📊 Balance update received:', balance); + balanceReceived = true; + + assert(balance !== null, WARN_SHOULD_BE_NOT_NULL); + assert(typeof balance === 'object', WARN_SHOULD_BE_OBJ); + + // Verify it's a balance-related event + const eventType = balance.e; + assert( + eventType === 'balanceUpdate' || eventType === 'outboundAccountPosition', + 'Should be a balance event type' + ); + + if (eventType === 'balanceUpdate') { + assert(Object.prototype.hasOwnProperty.call(balance, 'a'), 'Should have asset'); + assert(Object.prototype.hasOwnProperty.call(balance, 'd'), 'Should have delta'); + } else if (eventType === 'outboundAccountPosition') { + assert(Object.prototype.hasOwnProperty.call(balance, 'B'), 'Should have balances array'); + assert(Array.isArray(balance.B), 'Balances should be an array'); + } + + // Check if both events received + if (executionReceived && balanceReceived && subscriptionReady) { + console.log('✅ Both execution and balance events received!'); + setTimeout(() => { + stopWsApiConnections(true); + done(); + }, 2000); + } + }, + (execution) => { + // Execution callback + console.log('📈 Execution report received:', execution); + executionReceived = true; + + assert(execution !== null, WARN_SHOULD_BE_NOT_NULL); + assert(typeof execution === 'object', WARN_SHOULD_BE_OBJ); + assert(execution.e === 'executionReport', 'Should be execution report'); + + // Verify execution report structure + assert(Object.prototype.hasOwnProperty.call(execution, 's'), WARN_SHOULD_HAVE_KEY + 'symbol'); + assert(Object.prototype.hasOwnProperty.call(execution, 'S'), WARN_SHOULD_HAVE_KEY + 'side'); + assert(Object.prototype.hasOwnProperty.call(execution, 'o'), WARN_SHOULD_HAVE_KEY + 'order type'); + assert(Object.prototype.hasOwnProperty.call(execution, 'X'), WARN_SHOULD_HAVE_KEY + 'order status'); + assert(Object.prototype.hasOwnProperty.call(execution, 'x'), WARN_SHOULD_HAVE_KEY + 'execution type'); + + console.log(` Symbol: ${execution.s}`); + console.log(` Side: ${execution.S}`); + console.log(` Order Type: ${execution.o}`); + console.log(` Execution Type: ${execution.x}`); + console.log(` Order Status: ${execution.X}`); + + // Check if both events received + if (executionReceived && balanceReceived && subscriptionReady) { + console.log('✅ Both execution and balance events received!'); + setTimeout(() => { + stopWsApiConnections(true); + done(); + }, 2000); + } + }, + async (endpoint) => { + // Subscribed callback + console.log('Connected to user data stream:', endpoint); + subscriptionReady = true; + + assert(endpoint !== null, WARN_SHOULD_BE_NOT_NULL); + + // Wait a moment for WebSocket to be fully ready + await new Promise(resolve => setTimeout(resolve, 2000)); + + try { + // Create a small market buy order for BNBUSDT (typically has low price) + console.log('Creating test market order...'); + + const orderResult = await binance.marketBuy('BNBUSDT', 0.01); + + console.log('Order created:', orderResult); + assert(orderResult !== null, 'Order should be created'); + assert(orderResult.symbol === 'BNBUSDT', 'Order symbol should match'); + assert(orderResult.side === 'BUY', 'Order side should be BUY'); + assert(orderResult.type === 'MARKET', 'Order type should be MARKET'); + + // Events should be received automatically through WebSocket + // If events are not received within timeout, test will fail + console.log('Waiting for execution and balance events...'); + + // Set a backup timeout in case events are not received + setTimeout(() => { + if (!executionReceived || !balanceReceived) { + console.error('⚠️ Timeout: Not all events received'); + console.error(` Execution received: ${executionReceived}`); + console.error(` Balance received: ${balanceReceived}`); + stopWsApiConnections(true); + done(new Error('Did not receive all expected events within timeout')); + } + }, 25000); // 25 second timeout + + } catch (error: any) { + console.error('Error creating order:', error.message); + stopWsApiConnections(true); + done(error); + } + }, + (listStatus) => { + // List status callback + console.log('List status:', listStatus); + } + ); + }); + }); + + /* Skip margin in CI as does not support testnet + describe('userMarginData WebSocket API Connection', function () { + it('should connect and subscribe to margin data stream', function (done) { + this.timeout(TIMEOUT); + + binance.websockets.userMarginData( + (data) => { + // All updates callback + console.log('Margin data event:', data); + }, + (balance) => { + // Balance callback + console.log('Margin balance update:', balance); + }, + (execution) => { + // Execution callback + console.log('Margin execution report:', execution); + }, + (endpoint) => { + // Subscribed callback + console.log('Subscribed to margin stream:', endpoint); + + assert(endpoint !== null, WARN_SHOULD_BE_NOT_NULL); + assert(typeof endpoint === 'string', WARN_SHOULD_BE_TYPE + 'string'); + + // Verify subscription tracking + const subscriptionId = (binance as any).Options.marginDataSubscriptionId; + assert(subscriptionId !== undefined, 'Should have subscription ID'); + + // Wait a bit then cleanup + setTimeout(() => { + stopWsApiConnections(true); + done(); + }, 5000); + }, + (listStatus) => { + // List status callback + console.log('Margin list status:', listStatus); + } + ); + }); + }); + */ +}); diff --git a/tests/binance-ws-spot.test.ts b/tests/binance-ws-spot.test.ts index df91e1ff..9262de6b 100644 --- a/tests/binance-ws-spot.test.ts +++ b/tests/binance-ws-spot.test.ts @@ -9,14 +9,14 @@ const WARN_SHOULD_HAVE_KEY = 'should have key '; const WARN_SHOULD_NOT_HAVE_KEY = 'should not have key '; const WARN_SHOULD_BE_UNDEFINED = 'should be undefined'; const WARN_SHOULD_BE_TYPE = 'should be a '; -const TIMEOUT = 40000; +const TIMEOUT = 120000; const binance = new Binance().options({ APIKEY: 'X4BHNSimXOK6RKs2FcKqExquJtHjMxz5hWqF0BBeVnfa5bKFMk7X0wtkfEz0cPrJ', APISECRET: 'x8gLihunpNq0d46F2q0TWJmeCDahX5LMXSlv3lSFNbMI3rujSOpTDKdhbcmPSf2i', test: true, - httsProxy: 'http://188.245.226.105:8911' + httpsProxy: 'http://188.245.226.105:8911' }); const futuresBinance = new Binance().options({ @@ -302,7 +302,7 @@ describe( 'Websockets depth', function () { /*global beforeEach*/ beforeEach( function ( done ) { this.timeout( TIMEOUT ); - binance.websockets.depth( [ 'BNBBTC' ], e_depth => { + binance.websockets.depth( [ 'BTCUSDT' ], e_depth => { cnt++; if ( cnt > 1 ) return; depth = e_depth; @@ -323,7 +323,7 @@ describe( 'Websockets aggregated trades', function () { /*global beforeEach*/ beforeEach( function ( done ) { this.timeout( TIMEOUT ); - binance.websockets.aggTrades( [ 'BNBBTC', 'ETHBTC' ], e_trades => { + binance.websockets.aggTrades( [ 'BTCUSDT', 'ETHUSDT' ], e_trades => { cnt++; if ( cnt > 1 ) return; trades = e_trades; @@ -345,7 +345,7 @@ describe( 'Websockets (raw) trades', function () { /*global beforeEach*/ beforeEach( function ( done ) { this.timeout( TIMEOUT ); - binance.websockets.trades( [ 'BNBBTC', 'ETHBTC' ], e_trades => { + binance.websockets.trades( [ 'BTCUSDT', 'ETHUSDT' ], e_trades => { cnt++; if ( cnt > 1 ) return; trades = e_trades; @@ -358,4 +358,4 @@ describe( 'Websockets (raw) trades', function () { assert( typeof ( trades ) === 'object', WARN_SHOULD_BE_OBJ ); assert( trades !== null, WARN_SHOULD_BE_NOT_NULL ); } ); -} ); \ No newline at end of file +} ); diff --git a/tests/margin-ws-api-live.test.ts b/tests/margin-ws-api-live.test.ts new file mode 100644 index 00000000..02973eeb --- /dev/null +++ b/tests/margin-ws-api-live.test.ts @@ -0,0 +1,193 @@ +import Binance from '../src/node-binance-api'; +import { assert } from 'chai'; + +/** + * Live test for margin websocket API (ws-api branch). + * + * This test: + * 1. Connects to the margin user data stream via WebSocket API + * 2. Creates a LIMIT BUY order at a low price (won't fill) + * 3. Asserts an executionReport event is received via the websocket + * 4. Cancels the order and asserts a second executionReport (CANCELED) + * 5. Cleans up all connections + * + * Requirements: + * - APIKEY / APISECRET env vars with margin-enabled Binance account + * - Sufficient USDT balance in cross-margin account + * + * Run: + * APIKEY=xxx APISECRET=xxx npx ts-mocha tests/margin-ws-api-live.test.ts --timeout 120000 + */ + +const APIKEY = ''; +const APISECRET = ''; + +if (!APIKEY || !APISECRET) { + console.error('APIKEY and APISECRET env vars are required'); + process.exit(1); +} + +// Use a cheap pair to minimize balance requirements. +// XRPUSDT: ~$2.50, qty 5 => ~$10 notional at 80% price +const SYMBOL = process.env.SYMBOL || 'XRPUSDT'; +const TIMEOUT = 90000; + +const binance = new Binance().options({ + APIKEY, + APISECRET, + test: false, + verbose: true, +}); + +const stopWsApiConnections = function () { + const connections = (binance as any).wsApiConnections; + for (const connectionId in connections) { + console.log('Terminated WebSocket API connection:', connectionId); + (binance as any).terminateWsApi(connectionId); + } +}; + +describe('Margin WebSocket API – Live Order Test', function () { + + after(function () { + stopWsApiConnections(); + }); + + it('should receive executionReport events when placing and canceling a margin limit order', function (done) { + this.timeout(TIMEOUT); + + const events: any[] = []; + let orderId: number | string | null = null; + let newReceived = false; + let canceledReceived = false; + let finished = false; + + const finish = (err?: Error) => { + if (finished) return; + finished = true; + stopWsApiConnections(); + if (err) return done(err); + done(); + }; + + binance.websockets.userMarginData( + // all_updates_callback + (data: any) => { + console.log(' [all_updates]', data.e, data.s || ''); + }, + // balance_callback + (balance: any) => { + console.log(' [balance]', balance.e, balance.a || ''); + }, + // execution_callback + (execution: any) => { + console.log(' [execution]', execution.e, execution.x, execution.X, execution.s); + events.push(execution); + + assert(execution.e === 'executionReport', 'event type should be executionReport'); + assert(execution.s === SYMBOL, `symbol should be ${SYMBOL}`); + assert(execution.S !== undefined, 'should have side (S)'); + assert(execution.o !== undefined, 'should have order type (o)'); + assert(execution.X !== undefined, 'should have order status (X)'); + assert(execution.x !== undefined, 'should have execution type (x)'); + + // Step 3: We got the NEW event – now cancel the order + if (execution.x === 'NEW' && execution.X === 'NEW' && !newReceived) { + newReceived = true; + console.log('Received NEW executionReport – canceling order...'); + + // Use orderId from the event if not yet set + const cancelId = orderId || execution.i; + + binance.mgCancel(SYMBOL, cancelId).then((result: any) => { + console.log('Cancel result:', result.status || result); + }).catch((err: any) => { + console.error('Cancel error:', err.message); + finish(err); + }); + } + + // Step 4: We got the CANCELED event – assert and finish + if (execution.x === 'CANCELED' && execution.X === 'CANCELED' && !canceledReceived) { + canceledReceived = true; + console.log('Received CANCELED executionReport'); + + assert(events.length >= 2, 'should have received at least 2 execution events'); + + const newEvent = events.find(e => e.x === 'NEW'); + const cancelEvent = events.find(e => e.x === 'CANCELED'); + + assert(newEvent, 'should have a NEW execution event'); + assert(cancelEvent, 'should have a CANCELED execution event'); + assert(newEvent.o === 'LIMIT', 'NEW event order type should be LIMIT'); + assert(cancelEvent.o === 'LIMIT', 'CANCELED event order type should be LIMIT'); + + console.log('All assertions passed!'); + finish(); + } + }, + // subscribed_callback + async (endpoint: string) => { + console.log('Subscribed to margin data stream:', endpoint); + assert(endpoint !== null, 'endpoint should not be null'); + + const subscriptionId = (binance as any).Options.marginDataSubscriptionId; + assert(subscriptionId !== undefined, 'should have a subscription ID'); + console.log('Subscription ID:', subscriptionId); + + // Wait for WebSocket to stabilize + await new Promise(resolve => setTimeout(resolve, 2000)); + + try { + // Step 2: Fetch current price and place a limit buy well below market + const prices: any = await binance.prices(SYMBOL); + const currentPrice = parseFloat(prices[SYMBOL]); + // Set price 5% below market – passes PERCENT_PRICE_BY_SIDE but won't fill + const limitPrice = (currentPrice * 0.95).toFixed(4); + + // Use whole-number quantity that meets minimum notional + const minNotional = 10; + const quantity = Math.ceil(minNotional / parseFloat(limitPrice)); + const notional = quantity * parseFloat(limitPrice); + + console.log(`Current ${SYMBOL} price: ${currentPrice}`); + console.log(`Placing margin LIMIT BUY: ${quantity} @ ${limitPrice} (notional: ${notional.toFixed(2)} USDT)`); + + const orderResult = await binance.mgBuy(SYMBOL, quantity, parseFloat(limitPrice), { + sideEffectType: 'MARGIN_BUY' + }); + console.log('Order placed:', orderResult.orderId, orderResult.status); + + orderId = orderResult.orderId; + + assert(orderResult.symbol === SYMBOL, 'order symbol should match'); + assert(orderResult.side === 'BUY', 'order side should be BUY'); + assert(orderResult.type === 'LIMIT', 'order type should be LIMIT'); + + console.log('Waiting for executionReport events via WebSocket...'); + } catch (err: any) { + console.error('Error placing order:', err.body || err.message); + finish(err); + } + }, + // list_status_callback + (listStatus: any) => { + console.log(' [listStatus]', listStatus); + } + ); + + // Safety timeout – fail if we don't get all events in time + setTimeout(() => { + if (!finished) { + console.error('Timeout reached. Events received:', events.length); + events.forEach((e, i) => console.error(` event[${i}]:`, e.x, e.X)); + + // Best-effort cancel if order was placed but never canceled + if (orderId && !canceledReceived) { + binance.mgCancel(SYMBOL, orderId).catch(() => {}); + } + finish(new Error(`Timeout: newReceived=${newReceived}, canceledReceived=${canceledReceived}`)); + } + }, TIMEOUT - 10000); + }); +}); diff --git a/tests/package-test/test-cjs.cjs b/tests/package-test/test-cjs.cjs index cf95e960..de693f32 100644 --- a/tests/package-test/test-cjs.cjs +++ b/tests/package-test/test-cjs.cjs @@ -1,6 +1,6 @@ const Binance = require('node-binance-api'); -const client = new Binance({test: true}) +const client = new Binance({test: true, httpsProxy: 'http://188.245.226.105:8911'}) async function main() { const ticker = await client.prices('BTCUSDT') diff --git a/tests/package-test/test-esm.mjs b/tests/package-test/test-esm.mjs index 617b1e38..71c635ec 100644 --- a/tests/package-test/test-esm.mjs +++ b/tests/package-test/test-esm.mjs @@ -1,5 +1,5 @@ import Binance from 'node-binance-api' -const client = new Binance({test: true}) +const client = new Binance({test: true, httpsProxy: 'http://188.245.226.105:8911'}) async function main() { const ticker = await client.bookTickers('BTCUSDT')