Skip to content

Commit 848568e

Browse files
authored
Merge pull request #985 from pcriadoperez/ws-endpoints
feat: migrate USDⓈ-M futures WS to /public /market /private endpoints
2 parents b5453bf + 13a6fd8 commit 848568e

File tree

2 files changed

+289
-8
lines changed

2 files changed

+289
-8
lines changed

src/node-binance-api.ts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -294,13 +294,41 @@ export default class Binance {
294294
return this.dstreamSingle;
295295
}
296296

297-
getFStreamSingleUrl() {
297+
/**
298+
* Classify a futures stream endpoint into public, market, or private category
299+
* per Binance USDⓈ-M Futures WebSocket URL split (2026-03-06)
300+
*/
301+
classifyFuturesStream(endpoint: string): 'public' | 'market' | 'private' {
302+
// Public: bookTicker and depth streams (high-frequency)
303+
if (endpoint.includes('@bookTicker') || endpoint === '!bookTicker'
304+
|| endpoint.includes('@depth')) {
305+
return 'public';
306+
}
307+
// Private: listenKey is a long alphanumeric string (60+ chars, no @ or !)
308+
if (/^[A-Za-z0-9]{20,}$/.test(endpoint)) {
309+
return 'private';
310+
}
311+
// Market: aggTrade, markPrice, kline, ticker, miniTicker, forceOrder, etc.
312+
return 'market';
313+
}
314+
315+
getFStreamSingleUrl(category?: 'public' | 'market' | 'private') {
316+
if (category) {
317+
if (this.Options.demo) return `wss://fstream.binancefuture.com/${category}/ws/`;
318+
if (this.Options.test) return `wss://stream.binancefuture.${this.domain}/${category}/ws/`;
319+
return `wss://fstream.binance.${this.domain}/${category}/ws/`;
320+
}
298321
if (this.Options.demo) return this.fstreamSingleDemo;
299322
if (this.Options.test) return this.fstreamSingleTest;
300323
return this.fstreamSingle;
301324
}
302325

303-
getFStreamUrl() {
326+
getFStreamUrl(category?: 'public' | 'market' | 'private') {
327+
if (category) {
328+
if (this.Options.demo) return `wss://fstream.binancefuture.com/${category}/stream?streams=`;
329+
if (this.Options.test) return `wss://stream.binancefuture.${this.domain}/${category}/stream?streams=`;
330+
return `wss://fstream.binance.${this.domain}/${category}/stream?streams=`;
331+
}
304332
if (this.Options.demo) return this.fstreamDemo;
305333
if (this.Options.test) return this.fstreamTest;
306334
return this.fstream;
@@ -1772,6 +1800,12 @@ export default class Binance {
17721800
const httpsproxy = this.getHttpsProxy();
17731801
let socksproxy = this.getSocksProxy();
17741802
let ws: any = undefined;
1803+
const category = this.classifyFuturesStream(endpoint);
1804+
const baseUrl = this.getFStreamSingleUrl(category);
1805+
// Private streams use query params: ?listenKey=<key> instead of path: /<key>
1806+
const wsUrl = category === 'private'
1807+
? baseUrl.replace(/\/$/, '') + '?listenKey=' + endpoint
1808+
: baseUrl + endpoint;
17751809

17761810
if (socksproxy) {
17771811
socksproxy = this.proxyReplacewithIp(socksproxy);
@@ -1781,14 +1815,14 @@ export default class Binance {
17811815
host: this.parseProxy(socksproxy)[1],
17821816
port: this.parseProxy(socksproxy)[2]
17831817
});
1784-
ws = new WebSocket((this.getFStreamSingleUrl()) + endpoint, { agent });
1818+
ws = new WebSocket(wsUrl, { agent });
17851819
} else if (httpsproxy) {
17861820
const config = url.parse(httpsproxy);
17871821
const agent = new HttpsProxyAgent(config);
17881822
if (this.Options.verbose) this.Options.log(`futuresSubscribeSingle: using proxy server: ${agent}`);
1789-
ws = new WebSocket((this.getFStreamSingleUrl()) + endpoint, { agent });
1823+
ws = new WebSocket(wsUrl, { agent });
17901824
} else {
1791-
ws = new WebSocket((this.getFStreamSingleUrl()) + endpoint);
1825+
ws = new WebSocket(wsUrl);
17921826
}
17931827

17941828
if (this.Options.verbose) this.Options.log('futuresSubscribeSingle: Subscribed to ' + endpoint);
@@ -1827,6 +1861,8 @@ export default class Binance {
18271861
const httpsproxy = this.getHttpsProxy();
18281862
let socksproxy = this.getSocksProxy();
18291863
const queryParams = streams.join('/');
1864+
const category = this.classifyFuturesStream(streams[0]);
1865+
const baseUrl = this.getFStreamUrl(category);
18301866
let ws: any = undefined;
18311867
if (socksproxy) {
18321868
socksproxy = this.proxyReplacewithIp(socksproxy);
@@ -1836,14 +1872,14 @@ export default class Binance {
18361872
host: this.parseProxy(socksproxy)[1],
18371873
port: this.parseProxy(socksproxy)[2]
18381874
});
1839-
ws = new WebSocket(this.getFStreamUrl() + queryParams, { agent });
1875+
ws = new WebSocket(baseUrl + queryParams, { agent });
18401876
} else if (httpsproxy) {
18411877
if (this.Options.verbose) this.Options.log(`futuresSubscribe: using proxy server ${httpsproxy}`);
18421878
const config = url.parse(httpsproxy);
18431879
const agent = new HttpsProxyAgent(config);
1844-
ws = new WebSocket(this.getFStreamUrl() + queryParams, { agent });
1880+
ws = new WebSocket(baseUrl + queryParams, { agent });
18451881
} else {
1846-
ws = new WebSocket(this.getFStreamUrl() + queryParams);
1882+
ws = new WebSocket(baseUrl + queryParams);
18471883
}
18481884

18491885
ws.reconnect = this.Options.reconnect;
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import Binance from '../src/node-binance-api';
2+
import { assert } from 'chai';
3+
4+
const TIMEOUT = 30000;
5+
6+
// Production instance (no auth needed for public/market streams)
7+
const binance = new Binance();
8+
9+
// Demo instance for private stream test
10+
const demoBinance = new Binance().options({
11+
APIKEY: process.env.BINANCE_APIKEY || '',
12+
APISECRET: process.env.BINANCE_SECRET || '',
13+
demo: true,
14+
});
15+
16+
const stopSockets = function (instance) {
17+
const endpoints = instance.websockets.subscriptions();
18+
for (const endpoint in endpoints) {
19+
instance.websockets.terminate(endpoint);
20+
}
21+
};
22+
23+
describe('classifyFuturesStream', function () {
24+
it('classifies bookTicker as public', function () {
25+
assert.equal(binance.classifyFuturesStream('btcusdt@bookTicker'), 'public');
26+
assert.equal(binance.classifyFuturesStream('!bookTicker'), 'public');
27+
});
28+
29+
it('classifies depth streams as public', function () {
30+
assert.equal(binance.classifyFuturesStream('btcusdt@depth'), 'public');
31+
assert.equal(binance.classifyFuturesStream('btcusdt@depth@100ms'), 'public');
32+
assert.equal(binance.classifyFuturesStream('btcusdt@depth@500ms'), 'public');
33+
assert.equal(binance.classifyFuturesStream('btcusdt@depth5'), 'public');
34+
assert.equal(binance.classifyFuturesStream('btcusdt@depth10'), 'public');
35+
assert.equal(binance.classifyFuturesStream('btcusdt@depth20'), 'public');
36+
assert.equal(binance.classifyFuturesStream('btcusdt@depth5@100ms'), 'public');
37+
});
38+
39+
it('classifies aggTrade as market', function () {
40+
assert.equal(binance.classifyFuturesStream('btcusdt@aggTrade'), 'market');
41+
});
42+
43+
it('classifies markPrice as market', function () {
44+
assert.equal(binance.classifyFuturesStream('btcusdt@markPrice'), 'market');
45+
assert.equal(binance.classifyFuturesStream('btcusdt@markPrice@1s'), 'market');
46+
assert.equal(binance.classifyFuturesStream('!markPrice@arr'), 'market');
47+
assert.equal(binance.classifyFuturesStream('!markPrice@arr@1s'), 'market');
48+
});
49+
50+
it('classifies kline as market', function () {
51+
assert.equal(binance.classifyFuturesStream('btcusdt@kline_1m'), 'market');
52+
});
53+
54+
it('classifies ticker as market', function () {
55+
assert.equal(binance.classifyFuturesStream('btcusdt@ticker'), 'market');
56+
assert.equal(binance.classifyFuturesStream('!ticker@arr'), 'market');
57+
});
58+
59+
it('classifies miniTicker as market', function () {
60+
assert.equal(binance.classifyFuturesStream('btcusdt@miniTicker'), 'market');
61+
assert.equal(binance.classifyFuturesStream('!miniTicker@arr'), 'market');
62+
});
63+
64+
it('classifies forceOrder as market', function () {
65+
assert.equal(binance.classifyFuturesStream('btcusdt@forceOrder'), 'market');
66+
assert.equal(binance.classifyFuturesStream('!forceOrder@arr'), 'market');
67+
});
68+
69+
it('classifies compositeIndex as market', function () {
70+
assert.equal(binance.classifyFuturesStream('btcusdt@compositeIndex'), 'market');
71+
});
72+
73+
it('classifies contractInfo as market', function () {
74+
assert.equal(binance.classifyFuturesStream('!contractInfo'), 'market');
75+
});
76+
77+
it('classifies assetIndex as market', function () {
78+
assert.equal(binance.classifyFuturesStream('btcusdt@assetIndex'), 'market');
79+
assert.equal(binance.classifyFuturesStream('!assetIndex@arr'), 'market');
80+
});
81+
82+
it('classifies listenKey as private', function () {
83+
assert.equal(binance.classifyFuturesStream('pqia91ma19a5s61cv6a81va65sdf19v8a65a1a5s61cv6a81va65sdf19v8a1a65a1a5s61cv6a81va65sd'), 'private');
84+
});
85+
});
86+
87+
describe('getFStreamSingleUrl with category', function () {
88+
it('returns public ws URL', function () {
89+
assert.equal(binance.getFStreamSingleUrl('public'), 'wss://fstream.binance.com/public/ws/');
90+
});
91+
it('returns market ws URL', function () {
92+
assert.equal(binance.getFStreamSingleUrl('market'), 'wss://fstream.binance.com/market/ws/');
93+
});
94+
it('returns private ws URL', function () {
95+
assert.equal(binance.getFStreamSingleUrl('private'), 'wss://fstream.binance.com/private/ws/');
96+
});
97+
it('returns legacy URL without category', function () {
98+
assert.equal(binance.getFStreamSingleUrl(), 'wss://fstream.binance.com/ws/');
99+
});
100+
});
101+
102+
describe('getFStreamUrl with category', function () {
103+
it('returns public stream URL', function () {
104+
assert.equal(binance.getFStreamUrl('public'), 'wss://fstream.binance.com/public/stream?streams=');
105+
});
106+
it('returns market stream URL', function () {
107+
assert.equal(binance.getFStreamUrl('market'), 'wss://fstream.binance.com/market/stream?streams=');
108+
});
109+
it('returns private stream URL', function () {
110+
assert.equal(binance.getFStreamUrl('private'), 'wss://fstream.binance.com/private/stream?streams=');
111+
});
112+
it('returns legacy URL without category', function () {
113+
assert.equal(binance.getFStreamUrl(), 'wss://fstream.binance.com/stream?streams=');
114+
});
115+
});
116+
117+
describe('Demo mode uses category-based URLs', function () {
118+
it('getFStreamSingleUrl returns demo URL with category', function () {
119+
assert.equal(demoBinance.getFStreamSingleUrl('public'), 'wss://fstream.binancefuture.com/public/ws/');
120+
assert.equal(demoBinance.getFStreamSingleUrl('market'), 'wss://fstream.binancefuture.com/market/ws/');
121+
assert.equal(demoBinance.getFStreamSingleUrl('private'), 'wss://fstream.binancefuture.com/private/ws/');
122+
});
123+
it('getFStreamUrl returns demo URL with category', function () {
124+
assert.equal(demoBinance.getFStreamUrl('public'), 'wss://fstream.binancefuture.com/public/stream?streams=');
125+
assert.equal(demoBinance.getFStreamUrl('market'), 'wss://fstream.binancefuture.com/market/stream?streams=');
126+
assert.equal(demoBinance.getFStreamUrl('private'), 'wss://fstream.binancefuture.com/private/stream?streams=');
127+
});
128+
it('getFStreamSingleUrl returns legacy demo URL without category', function () {
129+
assert.equal(demoBinance.getFStreamSingleUrl(), 'wss://fstream.binancefuture.com/ws/');
130+
});
131+
it('getFStreamUrl returns legacy demo URL without category', function () {
132+
assert.equal(demoBinance.getFStreamUrl(), 'wss://fstream.binancefuture.com/stream?streams=');
133+
});
134+
});
135+
136+
describe('Private stream URL uses query params for listenKey', function () {
137+
it('constructs ?listenKey= URL instead of path-based', function () {
138+
const listenKey = 'pqia91ma19a5s61cv6a81va65sdf19v8a65a1a5s61cv6a81va65sdf19v8a1a65a1a5s61cv6a81va65sd';
139+
const category = binance.classifyFuturesStream(listenKey);
140+
const baseUrl = binance.getFStreamSingleUrl(category);
141+
const wsUrl = baseUrl.replace(/\/$/, '') + '?listenKey=' + listenKey;
142+
assert.equal(category, 'private');
143+
assert.equal(wsUrl, `wss://fstream.binance.com/private/ws?listenKey=${listenKey}`);
144+
});
145+
146+
it('constructs query-param URL for demo too', function () {
147+
const listenKey = 'pqia91ma19a5s61cv6a81va65sdf19v8a65a1a5s61cv6a81va65sdf19v8a1a65a1a5s61cv6a81va65sd';
148+
const category = demoBinance.classifyFuturesStream(listenKey);
149+
const baseUrl = demoBinance.getFStreamSingleUrl(category);
150+
const wsUrl = baseUrl.replace(/\/$/, '') + '?listenKey=' + listenKey;
151+
assert.equal(wsUrl, `wss://fstream.binancefuture.com/private/ws?listenKey=${listenKey}`);
152+
});
153+
});
154+
155+
describe('Live: production market stream (aggTrade via /market/)', function () {
156+
let trade;
157+
let cnt = 0;
158+
159+
beforeEach(function (done) {
160+
this.timeout(TIMEOUT);
161+
binance.futuresAggTradeStream('BTCUSDT', a_trade => {
162+
cnt++;
163+
if (cnt > 1) return;
164+
trade = a_trade;
165+
stopSockets(binance);
166+
done();
167+
});
168+
});
169+
170+
it('receives aggTrade data from /market/ endpoint', function () {
171+
assert(typeof trade === 'object', 'should be an object');
172+
assert(trade !== null, 'should not be null');
173+
assert(Object.prototype.hasOwnProperty.call(trade, 'symbol'), 'should have symbol');
174+
assert(Object.prototype.hasOwnProperty.call(trade, 'price'), 'should have price');
175+
});
176+
});
177+
178+
describe('Live: production public stream (bookTicker via /public/)', function () {
179+
let ticker;
180+
let cnt = 0;
181+
182+
beforeEach(function (done) {
183+
this.timeout(TIMEOUT);
184+
binance.futuresBookTickerStream('BTCUSDT', a_ticker => {
185+
cnt++;
186+
if (cnt > 1) return;
187+
ticker = a_ticker;
188+
stopSockets(binance);
189+
done();
190+
});
191+
});
192+
193+
it('receives bookTicker data from /public/ endpoint', function () {
194+
assert(typeof ticker === 'object', 'should be an object');
195+
assert(ticker !== null, 'should not be null');
196+
assert(Object.prototype.hasOwnProperty.call(ticker, 'bestBid'), 'should have bestBid');
197+
assert(Object.prototype.hasOwnProperty.call(ticker, 'bestAsk'), 'should have bestAsk');
198+
});
199+
});
200+
201+
describe('Live: demo private stream (userFutureData via /private/)', function () {
202+
let endpoint;
203+
204+
beforeEach(function (done) {
205+
this.timeout(TIMEOUT);
206+
demoBinance.userFutureData(
207+
undefined, // all_updates_callback
208+
undefined, // margin_call_callback
209+
undefined, // account_update_callback
210+
undefined, // order_update_callback
211+
(a_endpoint) => { // subscribed_callback
212+
endpoint = a_endpoint;
213+
stopSockets(demoBinance);
214+
done();
215+
}
216+
);
217+
});
218+
219+
it('connects to private user data stream successfully', function () {
220+
assert(endpoint !== undefined, 'should have received subscription endpoint');
221+
assert(typeof endpoint === 'string', 'endpoint should be a string');
222+
});
223+
});
224+
225+
describe('Live: production combined market stream (kline via /market/)', function () {
226+
let candle;
227+
let cnt = 0;
228+
229+
beforeEach(function (done) {
230+
this.timeout(TIMEOUT);
231+
binance.futuresCandlesticksStream(['BTCUSDT'], '1m', a_candle => {
232+
cnt++;
233+
if (cnt > 1) return;
234+
candle = a_candle;
235+
stopSockets(binance);
236+
done();
237+
});
238+
});
239+
240+
it('receives kline data from /market/ combined stream', function () {
241+
assert(typeof candle === 'object', 'should be an object');
242+
assert(candle !== null, 'should not be null');
243+
assert(Object.prototype.hasOwnProperty.call(candle, 'k'), 'should have kline data');
244+
});
245+
});

0 commit comments

Comments
 (0)