Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 44 additions & 8 deletions src/node-binance-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,13 +294,41 @@ export default class Binance {
return this.dstreamSingle;
}

getFStreamSingleUrl() {
/**
* Classify a futures stream endpoint into public, market, or private category
* per Binance USDⓈ-M Futures WebSocket URL split (2026-03-06)
*/
classifyFuturesStream(endpoint: string): 'public' | 'market' | 'private' {
// Public: bookTicker and depth streams (high-frequency)
if (endpoint.includes('@bookTicker') || endpoint === '!bookTicker'
|| endpoint.includes('@depth')) {
return 'public';
}
// Private: listenKey is a long alphanumeric string (60+ chars, no @ or !)
if (/^[A-Za-z0-9]{20,}$/.test(endpoint)) {
return 'private';
}
// Market: aggTrade, markPrice, kline, ticker, miniTicker, forceOrder, etc.
return 'market';
}

getFStreamSingleUrl(category?: 'public' | 'market' | 'private') {
if (category) {
if (this.Options.demo) return `wss://fstream.binancefuture.com/${category}/ws/`;
if (this.Options.test) return `wss://stream.binancefuture.${this.domain}/${category}/ws/`;
return `wss://fstream.binance.${this.domain}/${category}/ws/`;
}
if (this.Options.demo) return this.fstreamSingleDemo;
if (this.Options.test) return this.fstreamSingleTest;
return this.fstreamSingle;
}

getFStreamUrl() {
getFStreamUrl(category?: 'public' | 'market' | 'private') {
if (category) {
if (this.Options.demo) return `wss://fstream.binancefuture.com/${category}/stream?streams=`;
if (this.Options.test) return `wss://stream.binancefuture.${this.domain}/${category}/stream?streams=`;
return `wss://fstream.binance.${this.domain}/${category}/stream?streams=`;
}
if (this.Options.demo) return this.fstreamDemo;
if (this.Options.test) return this.fstreamTest;
return this.fstream;
Expand Down Expand Up @@ -1772,6 +1800,12 @@ export default class Binance {
const httpsproxy = this.getHttpsProxy();
let socksproxy = this.getSocksProxy();
let ws: any = undefined;
const category = this.classifyFuturesStream(endpoint);
const baseUrl = this.getFStreamSingleUrl(category);
// Private streams use query params: ?listenKey=<key> instead of path: /<key>
const wsUrl = category === 'private'
? baseUrl.replace(/\/$/, '') + '?listenKey=' + endpoint
: baseUrl + endpoint;

if (socksproxy) {
socksproxy = this.proxyReplacewithIp(socksproxy);
Expand All @@ -1781,14 +1815,14 @@ export default class Binance {
host: this.parseProxy(socksproxy)[1],
port: this.parseProxy(socksproxy)[2]
});
ws = new WebSocket((this.getFStreamSingleUrl()) + endpoint, { agent });
ws = new WebSocket(wsUrl, { agent });
} else if (httpsproxy) {
const config = url.parse(httpsproxy);
const agent = new HttpsProxyAgent(config);
if (this.Options.verbose) this.Options.log(`futuresSubscribeSingle: using proxy server: ${agent}`);
ws = new WebSocket((this.getFStreamSingleUrl()) + endpoint, { agent });
ws = new WebSocket(wsUrl, { agent });
} else {
ws = new WebSocket((this.getFStreamSingleUrl()) + endpoint);
ws = new WebSocket(wsUrl);
}

if (this.Options.verbose) this.Options.log('futuresSubscribeSingle: Subscribed to ' + endpoint);
Expand Down Expand Up @@ -1827,6 +1861,8 @@ export default class Binance {
const httpsproxy = this.getHttpsProxy();
let socksproxy = this.getSocksProxy();
const queryParams = streams.join('/');
const category = this.classifyFuturesStream(streams[0]);
const baseUrl = this.getFStreamUrl(category);
let ws: any = undefined;
if (socksproxy) {
socksproxy = this.proxyReplacewithIp(socksproxy);
Expand All @@ -1836,14 +1872,14 @@ export default class Binance {
host: this.parseProxy(socksproxy)[1],
port: this.parseProxy(socksproxy)[2]
});
ws = new WebSocket(this.getFStreamUrl() + queryParams, { agent });
ws = new WebSocket(baseUrl + queryParams, { agent });
} else if (httpsproxy) {
if (this.Options.verbose) this.Options.log(`futuresSubscribe: using proxy server ${httpsproxy}`);
const config = url.parse(httpsproxy);
const agent = new HttpsProxyAgent(config);
ws = new WebSocket(this.getFStreamUrl() + queryParams, { agent });
ws = new WebSocket(baseUrl + queryParams, { agent });
} else {
ws = new WebSocket(this.getFStreamUrl() + queryParams);
ws = new WebSocket(baseUrl + queryParams);
}

ws.reconnect = this.Options.reconnect;
Expand Down
245 changes: 245 additions & 0 deletions tests/ws-endpoints-migration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import Binance from '../src/node-binance-api';
import { assert } from 'chai';

const TIMEOUT = 30000;

// Production instance (no auth needed for public/market streams)
const binance = new Binance();

// Demo instance for private stream test
const demoBinance = new Binance().options({
APIKEY: process.env.BINANCE_APIKEY || '',
APISECRET: process.env.BINANCE_SECRET || '',
demo: true,
});

const stopSockets = function (instance) {
const endpoints = instance.websockets.subscriptions();
for (const endpoint in endpoints) {
instance.websockets.terminate(endpoint);
}
};

describe('classifyFuturesStream', function () {
it('classifies bookTicker as public', function () {
assert.equal(binance.classifyFuturesStream('btcusdt@bookTicker'), 'public');
assert.equal(binance.classifyFuturesStream('!bookTicker'), 'public');
});

it('classifies depth streams as public', function () {
assert.equal(binance.classifyFuturesStream('btcusdt@depth'), 'public');
assert.equal(binance.classifyFuturesStream('btcusdt@depth@100ms'), 'public');
assert.equal(binance.classifyFuturesStream('btcusdt@depth@500ms'), 'public');
assert.equal(binance.classifyFuturesStream('btcusdt@depth5'), 'public');
assert.equal(binance.classifyFuturesStream('btcusdt@depth10'), 'public');
assert.equal(binance.classifyFuturesStream('btcusdt@depth20'), 'public');
assert.equal(binance.classifyFuturesStream('btcusdt@depth5@100ms'), 'public');
});

it('classifies aggTrade as market', function () {
assert.equal(binance.classifyFuturesStream('btcusdt@aggTrade'), 'market');
});

it('classifies markPrice as market', function () {
assert.equal(binance.classifyFuturesStream('btcusdt@markPrice'), 'market');
assert.equal(binance.classifyFuturesStream('btcusdt@markPrice@1s'), 'market');
assert.equal(binance.classifyFuturesStream('!markPrice@arr'), 'market');
assert.equal(binance.classifyFuturesStream('!markPrice@arr@1s'), 'market');
});

it('classifies kline as market', function () {
assert.equal(binance.classifyFuturesStream('btcusdt@kline_1m'), 'market');
});

it('classifies ticker as market', function () {
assert.equal(binance.classifyFuturesStream('btcusdt@ticker'), 'market');
assert.equal(binance.classifyFuturesStream('!ticker@arr'), 'market');
});

it('classifies miniTicker as market', function () {
assert.equal(binance.classifyFuturesStream('btcusdt@miniTicker'), 'market');
assert.equal(binance.classifyFuturesStream('!miniTicker@arr'), 'market');
});

it('classifies forceOrder as market', function () {
assert.equal(binance.classifyFuturesStream('btcusdt@forceOrder'), 'market');
assert.equal(binance.classifyFuturesStream('!forceOrder@arr'), 'market');
});

it('classifies compositeIndex as market', function () {
assert.equal(binance.classifyFuturesStream('btcusdt@compositeIndex'), 'market');
});

it('classifies contractInfo as market', function () {
assert.equal(binance.classifyFuturesStream('!contractInfo'), 'market');
});

it('classifies assetIndex as market', function () {
assert.equal(binance.classifyFuturesStream('btcusdt@assetIndex'), 'market');
assert.equal(binance.classifyFuturesStream('!assetIndex@arr'), 'market');
});

it('classifies listenKey as private', function () {
assert.equal(binance.classifyFuturesStream('pqia91ma19a5s61cv6a81va65sdf19v8a65a1a5s61cv6a81va65sdf19v8a1a65a1a5s61cv6a81va65sd'), 'private');
});
});

describe('getFStreamSingleUrl with category', function () {
it('returns public ws URL', function () {
assert.equal(binance.getFStreamSingleUrl('public'), 'wss://fstream.binance.com/public/ws/');
});
it('returns market ws URL', function () {
assert.equal(binance.getFStreamSingleUrl('market'), 'wss://fstream.binance.com/market/ws/');
});
it('returns private ws URL', function () {
assert.equal(binance.getFStreamSingleUrl('private'), 'wss://fstream.binance.com/private/ws/');
});
it('returns legacy URL without category', function () {
assert.equal(binance.getFStreamSingleUrl(), 'wss://fstream.binance.com/ws/');
});
});

describe('getFStreamUrl with category', function () {
it('returns public stream URL', function () {
assert.equal(binance.getFStreamUrl('public'), 'wss://fstream.binance.com/public/stream?streams=');
});
it('returns market stream URL', function () {
assert.equal(binance.getFStreamUrl('market'), 'wss://fstream.binance.com/market/stream?streams=');
});
it('returns private stream URL', function () {
assert.equal(binance.getFStreamUrl('private'), 'wss://fstream.binance.com/private/stream?streams=');
});
it('returns legacy URL without category', function () {
assert.equal(binance.getFStreamUrl(), 'wss://fstream.binance.com/stream?streams=');
});
});

describe('Demo mode uses category-based URLs', function () {
it('getFStreamSingleUrl returns demo URL with category', function () {
assert.equal(demoBinance.getFStreamSingleUrl('public'), 'wss://fstream.binancefuture.com/public/ws/');
assert.equal(demoBinance.getFStreamSingleUrl('market'), 'wss://fstream.binancefuture.com/market/ws/');
assert.equal(demoBinance.getFStreamSingleUrl('private'), 'wss://fstream.binancefuture.com/private/ws/');
});
it('getFStreamUrl returns demo URL with category', function () {
assert.equal(demoBinance.getFStreamUrl('public'), 'wss://fstream.binancefuture.com/public/stream?streams=');
assert.equal(demoBinance.getFStreamUrl('market'), 'wss://fstream.binancefuture.com/market/stream?streams=');
assert.equal(demoBinance.getFStreamUrl('private'), 'wss://fstream.binancefuture.com/private/stream?streams=');
});
it('getFStreamSingleUrl returns legacy demo URL without category', function () {
assert.equal(demoBinance.getFStreamSingleUrl(), 'wss://fstream.binancefuture.com/ws/');
});
it('getFStreamUrl returns legacy demo URL without category', function () {
assert.equal(demoBinance.getFStreamUrl(), 'wss://fstream.binancefuture.com/stream?streams=');
});
});

describe('Private stream URL uses query params for listenKey', function () {
it('constructs ?listenKey= URL instead of path-based', function () {
const listenKey = 'pqia91ma19a5s61cv6a81va65sdf19v8a65a1a5s61cv6a81va65sdf19v8a1a65a1a5s61cv6a81va65sd';
const category = binance.classifyFuturesStream(listenKey);
const baseUrl = binance.getFStreamSingleUrl(category);
const wsUrl = baseUrl.replace(/\/$/, '') + '?listenKey=' + listenKey;
assert.equal(category, 'private');
assert.equal(wsUrl, `wss://fstream.binance.com/private/ws?listenKey=${listenKey}`);
});

it('constructs query-param URL for demo too', function () {
const listenKey = 'pqia91ma19a5s61cv6a81va65sdf19v8a65a1a5s61cv6a81va65sdf19v8a1a65a1a5s61cv6a81va65sd';
const category = demoBinance.classifyFuturesStream(listenKey);
const baseUrl = demoBinance.getFStreamSingleUrl(category);
const wsUrl = baseUrl.replace(/\/$/, '') + '?listenKey=' + listenKey;
assert.equal(wsUrl, `wss://fstream.binancefuture.com/private/ws?listenKey=${listenKey}`);
});
});

describe('Live: production market stream (aggTrade via /market/)', function () {
let trade;
let cnt = 0;

beforeEach(function (done) {
this.timeout(TIMEOUT);
binance.futuresAggTradeStream('BTCUSDT', a_trade => {
cnt++;
if (cnt > 1) return;
trade = a_trade;
stopSockets(binance);
done();
});
});

it('receives aggTrade data from /market/ endpoint', function () {
assert(typeof trade === 'object', 'should be an object');
assert(trade !== null, 'should not be null');
assert(Object.prototype.hasOwnProperty.call(trade, 'symbol'), 'should have symbol');
assert(Object.prototype.hasOwnProperty.call(trade, 'price'), 'should have price');
});
});

describe('Live: production public stream (bookTicker via /public/)', function () {
let ticker;
let cnt = 0;

beforeEach(function (done) {
this.timeout(TIMEOUT);
binance.futuresBookTickerStream('BTCUSDT', a_ticker => {
cnt++;
if (cnt > 1) return;
ticker = a_ticker;
stopSockets(binance);
done();
});
});

it('receives bookTicker data from /public/ endpoint', function () {
assert(typeof ticker === 'object', 'should be an object');
assert(ticker !== null, 'should not be null');
assert(Object.prototype.hasOwnProperty.call(ticker, 'bestBid'), 'should have bestBid');
assert(Object.prototype.hasOwnProperty.call(ticker, 'bestAsk'), 'should have bestAsk');
});
});

describe('Live: demo private stream (userFutureData via /private/)', function () {
let endpoint;

beforeEach(function (done) {
this.timeout(TIMEOUT);
demoBinance.userFutureData(
undefined, // all_updates_callback
undefined, // margin_call_callback
undefined, // account_update_callback
undefined, // order_update_callback
(a_endpoint) => { // subscribed_callback
endpoint = a_endpoint;
stopSockets(demoBinance);
done();
}
);
});

it('connects to private user data stream successfully', function () {
assert(endpoint !== undefined, 'should have received subscription endpoint');
assert(typeof endpoint === 'string', 'endpoint should be a string');
});
});

describe('Live: production combined market stream (kline via /market/)', function () {
let candle;
let cnt = 0;

beforeEach(function (done) {
this.timeout(TIMEOUT);
binance.futuresCandlesticksStream(['BTCUSDT'], '1m', a_candle => {
cnt++;
if (cnt > 1) return;
candle = a_candle;
stopSockets(binance);
done();
});
});

it('receives kline data from /market/ combined stream', function () {
assert(typeof candle === 'object', 'should be an object');
assert(candle !== null, 'should not be null');
assert(Object.prototype.hasOwnProperty.call(candle, 'k'), 'should have kline data');
});
});
Loading