trade-data-generator
Version:
Generate realistic real-time market data for trading UIs — equity, forex and crypto. Emits tick, candle, orderbook and market status events via EventEmitter.
424 lines (360 loc) • 11.2 kB
JavaScript
/**
* binance.js
*
* Binance WebSocket connector.
* Connects to Binance public WebSocket streams.
* No API key required for market data.
*
* Streams used:
* <symbol>@ticker → 24hr ticker (price, change, volume)
* <symbol>@depth10 → order book top 10 levels
* <symbol>@kline_1m → 1 minute candles
*
* source: 'binance'
*/
"use strict";
const { BaseConnector } = require("./base");
// Binance WebSocket base URL
const WS_BASE = "wss://stream.binance.com:9443/stream?streams=";
// Candle interval map — our format to Binance format
const INTERVAL_MAP = {
"1s": "1s",
"1m": "1m",
"5m": "5m",
"15m": "15m",
"1h": "1h",
"4h": "4h",
"1d": "1d",
};
class BinanceConnector extends BaseConnector {
/**
* @param {Object} config
* @param {Array} config.pairs - pair configs
* @param {string[]} config.candleIntervals - e.g. ['1m', '5m']
* @param {number} config.depth - order book levels (max 20)
* @param {Function} config.onTick
* @param {Function} config.onCandle
* @param {Function} config.onDepth
* @param {Function} config.onError
*/
constructor(config = {}) {
super(config);
this._name = "binance";
this._type = config.type ?? "crypto";
this._candleIntervals = config.candleIntervals ?? ["1m"];
this._depth = Math.min(config.depth ?? 10, 20);
this._ws = null;
this._reconnectTimer = null;
this._reconnectDelay = 3000; // 3 seconds
this._maxReconnects = 5;
this._reconnectCount = 0;
// Track last price per symbol for change calculation
this._lastPrice = new Map();
this._open24h = new Map();
}
// ── Lifecycle ─────────────────────────── //
async connect() {
if (this._running) return;
this._running = true;
// Build stream URL from pairs and intervals
const streams = this._buildStreams();
if (streams.length === 0) {
this._emitError("*", new Error("No pairs configured"));
return;
}
this._openWebSocket(streams);
}
async disconnect() {
this._running = false;
this._reconnectCount = 0;
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
if (this._ws) {
this._ws.close();
this._ws = null;
}
}
async subscribe(symbols) {
// Dynamic subscription via WebSocket SUBSCRIBE message
if (!this._ws || this._ws.readyState !== 1) return;
const streams = this._symbolsToStreams(symbols);
const msg = {
method: "SUBSCRIBE",
params: streams,
id: Date.now(),
};
this._ws.send(JSON.stringify(msg));
}
async unsubscribe(symbols) {
if (!this._ws || this._ws.readyState !== 1) return;
const streams = this._symbolsToStreams(symbols);
const msg = {
method: "UNSUBSCRIBE",
params: streams,
id: Date.now(),
};
this._ws.send(JSON.stringify(msg));
}
// ── Normalization ─────────────────────── //
normalizeTick(raw) {
// raw = Binance 24hr ticker stream data
// https://binance-docs.github.io/apidocs/spot/en/#individual-symbol-mini-ticker-stream
const symbol = this._binanceToStandard(raw.s);
const price = parseFloat(raw.c);
const open24h = parseFloat(raw.o);
const prev = this._lastPrice.get(symbol) ?? price;
this._lastPrice.set(symbol, price);
this._open24h.set(symbol, open24h);
const change = parseFloat((price - open24h).toFixed(8));
const changePct = parseFloat(
(((price - open24h) / open24h) * 100).toFixed(4),
);
// Bid/ask from ticker (approximate spread)
const bid = parseFloat(raw.b) || parseFloat((price * 0.9995).toFixed(8));
const ask = parseFloat(raw.a) || parseFloat((price * 1.0005).toFixed(8));
return {
symbol,
type: this._type,
timestamp: raw.E ?? Date.now(),
price,
bid,
ask,
spread: parseFloat((ask - bid).toFixed(8)),
volume: parseFloat(raw.v),
change,
changePct,
high24h: parseFloat(raw.h),
low24h: parseFloat(raw.l),
open24h,
previous: prev,
source: "binance",
};
}
normalizeCandle(raw) {
// raw = Binance kline stream data
const k = raw.k;
const symbol = this._binanceToStandard(k.s);
return {
symbol,
type: this._type,
interval: this._binanceToInterval(k.i),
openTime: k.t,
closeTime: k.T,
open: parseFloat(k.o),
high: parseFloat(k.h),
low: parseFloat(k.l),
close: parseFloat(k.c),
volume: parseFloat(k.v),
closed: k.x, // true when candle is closed
source: "binance",
};
}
normalizeDepth(raw) {
// raw = Binance partial book depth stream
// Caller injects s as an already-converted standard symbol (e.g. 'BTC/USDT')
const symbol = raw.s ?? "";
const bids = (raw.bids ?? []).slice(0, this._depth).map((b) => ({
price: parseFloat(b[0]),
volume: parseFloat(b[1]),
side: "bid",
}));
const asks = (raw.asks ?? []).slice(0, this._depth).map((a) => ({
price: parseFloat(a[0]),
volume: parseFloat(a[1]),
side: "ask",
}));
return {
symbol,
type: this._type,
timestamp: Date.now(),
bids,
asks,
source: "binance",
};
}
// ── State ─────────────────────────────── //
getState(symbol) {
// Live connector — return last known price
const price = this._lastPrice.get(symbol);
if (!price) return null;
return {
symbol,
type: this._type,
source: "binance",
price,
open24h: this._open24h.get(symbol) ?? price,
};
}
// ── Private ───────────────────────────── //
/**
* Open WebSocket connection to Binance
*/
_openWebSocket(streams) {
const url = WS_BASE + streams.join("/");
try {
// Use native WebSocket (Node.js 18+) or ws package
const WS = typeof WebSocket !== "undefined" ? WebSocket : require("ws");
this._ws = new WS(url);
this._ws.onopen = () => {
console.log(`[BinanceConnector] Connected — ${streams.length} streams`);
this._reconnectCount = 0;
};
this._ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
this._handleMessage(msg);
} catch (err) {
this._emitError("*", err);
}
};
this._ws.onerror = (err) => {
this._emitError("*", new Error(`WebSocket error: ${err.message}`));
};
this._ws.onclose = () => {
if (!this._running) return;
console.warn(
"[BinanceConnector] Disconnected — attempting reconnect...",
);
this._reconnect(streams);
};
} catch (err) {
this._emitError("*", err);
}
}
/**
* Handle incoming WebSocket message
*/
_handleMessage(msg) {
if (!msg.data || !msg.stream) return;
const stream = msg.stream;
const data = msg.data;
// 24hr ticker stream
if (stream.endsWith("@ticker")) {
const tick = this.normalizeTick(data);
this._emitTick(tick);
return;
}
// Kline/candle stream
if (stream.includes("@kline_")) {
const candle = this.normalizeCandle(data);
// Emit every update so the chart renders in real-time; candle.closed signals a finalised bar
this._emitCandle(candle);
return;
}
// Depth stream
if (stream.endsWith("@depth10") || stream.includes("@depth")) {
// Depth stream payload has no 's' field — extract symbol from stream name
const rawSymbol = stream.split("@")[0].toUpperCase(); // e.g. 'BTCUSDT'
const symbol = this._binanceToStandard(rawSymbol); // e.g. 'BTC/USDT'
console.log("[BinanceConnector] depth stream:", stream, "→ symbol:", symbol, "| bids:", data.bids?.length, "asks:", data.asks?.length);
const depth = this.normalizeDepth({ ...data, s: symbol });
this._emitDepth(depth);
return;
}
}
/**
* Reconnect with exponential backoff
*/
_reconnect(streams) {
if (this._reconnectCount >= this._maxReconnects) {
this._emitError(
"*",
new Error(
`[BinanceConnector] Max reconnect attempts (${this._maxReconnects}) reached.`,
),
);
return;
}
this._reconnectCount++;
const delay = this._reconnectDelay * this._reconnectCount;
console.log(
`[BinanceConnector] Reconnecting in ${delay}ms... (attempt ${this._reconnectCount})`,
);
this._reconnectTimer = setTimeout(() => {
this._openWebSocket(streams);
}, delay);
}
/**
* Build Binance stream names from pairs and intervals
* e.g. ['btcusdt@ticker', 'btcusdt@depth10', 'btcusdt@kline_1m']
*/
_buildStreams() {
const streams = [];
this.pairs.forEach((pair) => {
const binanceSymbol = this._standardToBinance(pair.symbol);
// Ticker stream — price, volume, 24h stats
streams.push(`${binanceSymbol}`);
// Depth stream — order book
streams.push(`${binanceSymbol}${this._depth}`);
// Kline streams — one per interval
this._candleIntervals.forEach((interval) => {
const binanceInterval = INTERVAL_MAP[interval];
if (binanceInterval) {
streams.push(`${binanceSymbol}${binanceInterval}`);
}
});
});
return streams;
}
/**
* Build streams for specific symbols (for dynamic subscribe/unsubscribe)
*/
_symbolsToStreams(symbols) {
const streams = [];
symbols.forEach((symbol) => {
const binanceSymbol = this._standardToBinance(symbol);
streams.push(`${binanceSymbol}`);
streams.push(`${binanceSymbol}${this._depth}`);
this._candleIntervals.forEach((interval) => {
streams.push(
`${binanceSymbol}${INTERVAL_MAP[interval] ?? "1m"}`,
);
});
});
return streams;
}
/**
* Convert standard symbol to Binance format
* 'BTC/USDT' → 'btcusdt'
*/
_standardToBinance(symbol) {
return symbol.replace("/", "").toLowerCase();
}
/**
* Convert Binance symbol to standard format
* 'BTCUSDT' → 'BTC/USDT'
*
* Handles common quote currencies:
* USDT, USDC, BTC, ETH, BNB, BUSD
*/
_binanceToStandard(symbol) {
if (symbol.includes('/')) return symbol
const quotes = ["USDT", "USDC", "BUSD", "BTC", "ETH", "BNB", "TRX", "XRP"];
for (const quote of quotes) {
if (symbol.endsWith(quote)) {
const base = symbol.slice(0, -quote.length);
return `${base}/${quote}`;
}
}
return symbol;
}
/**
* Convert Binance interval to standard format
* '1m' → '1m' (same in this case)
*/
_binanceToInterval(interval) {
const map = {
"1s": "1s",
"1m": "1m",
"5m": "5m",
"15m": "15m",
"1h": "1h",
"4h": "4h",
"1d": "1d",
};
return map[interval] ?? interval;
}
}
module.exports = { BinanceConnector };