// ============================================================ // binance.ts — Binance 交易所适配器 // ============================================================ // 基于 Binance 官方 SDK(binance@3.x)实现 MarketDataFeed 接口。 // // WebSocket:使用 SDK 内置 WebsocketClient,自动处理多路复用、 // 断线重连、心跳保活。通过 formattedMessage 事件接收已解析的 // 类型化行情数据,转换为本系统标准化结构后通过 RxJS Subject 发布。 // // REST:使用 SDK 内置 MainClient(Spot),用于: // - fetchKlines() 历史 K 线补拉 // - fetchMarkets() 交易对元数据(用于自动注册到 trading_pairs 表) // // ============================================================ // 风险提示: // - Binance WebSocket 单连接最多订阅 1024 个 stream, // 超出需拆分多连接(SDK 自动处理) // - 生产环境建议使用 Binance 的 combined streams 合并请求 // - REST API 限频:1200 请求/分钟(权重制),fetchKlines 权重 2 // ============================================================ import { Subject, type Observable } from "rxjs"; import { WebsocketClient, MainClient, type WsMessageKlineFormatted, type WsMessageTradeFormatted, type WsMessage24hrTickerFormatted, type WsMessageBookTickerEventFormatted, type WsMessagePartialBookDepthEventFormatted, type WsFormattedMessage, } from "binance"; import type { Kline as BinanceRestKline } from "binance"; import { BaseExchangeAdapter } from "./base"; import { logger } from "../utils/logger"; import type { Ticker, Trade, OrderBook, Kline, KlineInterval, MarketInfo, AdapterConfig, BinanceRawKline, } from "./types"; import { KLINE_INTERVAL_MS } from "./types"; // ============================================================ // Binance K 线周期 ← → 本系统 K 线周期映射 // ============================================================ /** * Binance SDK 支持的 K 线周期(比本系统更多)。 * 本系统仅使用其中的子集,其余周期由 pipeline 合成。 */ type BinanceKlineInterval = | "1m" | "5m" | "15m" | "30m" | "1h" | "4h" | "1d" | "1w"; /** 本系统 KlineInterval → Binance SDK KlineInterval(1:1 子集映射) */ const INTERVAL_TO_BINANCE: Record = { "1m": "1m", "5m": "5m", "15m": "15m", "30m": "30m", "1h": "1h", "4h": "4h", "1d": "1d", "1w": "1w", }; // ============================================================ // 默认适配器配置(Binance 专用覆盖) // ============================================================ const DEFAULT_BINANCE_CONFIG: AdapterConfig = { reconnectBaseDelayMs: 3000, maxReconnectAttempts: 10, /** Binance REST API 权重制限频,保守设为 250ms */ restRateLimitMs: 250, }; // ============================================================ // BinanceAdapter // ============================================================ export class BinanceAdapter extends BaseExchangeAdapter { readonly exchange = "binance"; // ---------------------------------------------------------- // SDK 客户端实例 // ---------------------------------------------------------- /** Binance WebSocket 客户端(内置多路复用 + 自动重连) */ private wsClient!: WebsocketClient; /** Binance REST 客户端(Spot) */ private restClient!: MainClient; // ---------------------------------------------------------- // RxJS Subject —— 按事件类型分频道发布 // ---------------------------------------------------------- /** 24h Ticker 流(合并所有已订阅 symbol) */ private tickerSubject!: Subject; /** 逐笔成交流(合并所有已订阅 symbol) */ private tradeSubject!: Subject; /** 订单簿深度流(合并所有已订阅 symbol) */ private orderbookSubjects = new Map>(); // ---------------------------------------------------------- // 订阅追踪 // ---------------------------------------------------------- /** 当前已订阅的 ticker symbol 集合 */ private subscribedTickerSymbols = new Set(); /** 当前已订阅的 trade symbol 集合 */ private subscribedTradeSymbols = new Set(); /** 当前已订阅的 orderbook symbol → depth 映射 */ private subscribedOrderbookDepths = new Map(); /** 防止重复 REST 请求的节流 Map(symbol:interval → lastFetchTime) */ private lastRestFetch = new Map(); // ============================================================ // 构造函数 // ============================================================ constructor(config: Partial = {}) { super({ ...DEFAULT_BINANCE_CONFIG, ...config }); } // ============================================================ // 连接管理 // ============================================================ /** * 建立 WebSocket 连接并注册事件监听。 * * Binance SDK 的 WebsocketClient 在首次 subscribe() 时自动建连, * 此处主动调用 connectPublic() 预热连接并注册 formattedMessage 监听。 */ async connect(): Promise { if (this._connectionState === "connected") { logger.debug(`[binance] 已连接,跳过重复 connect`); return; } this.setConnectionState("connecting"); try { // 初始化 WebSocket 客户端 this.wsClient = new WebsocketClient({ // 生产环境使用 Binance 主网 // useTestnet: false 为默认值 }); // 初始化 REST 客户端(公开接口无需 API Key) this.restClient = new MainClient(); // 注册 formattedMessage 事件 —— SDK 将原始 JSON 解析为类型化对象 this.wsClient.on("formattedMessage", this.onFormattedMessage.bind(this)); // 注册重连事件(利用 SDK 内置的自动重连) this.wsClient.on("reconnecting", (evt) => { logger.warn( { wsKey: evt.wsKey }, `[binance] WebSocket 重连中...`, ); this.setConnectionState("connecting"); }); this.wsClient.on("reconnected", (evt) => { logger.info( { wsKey: evt.wsKey, wsUrl: evt.wsUrl }, `[binance] WebSocket 重连成功`, ); this.setConnectionState("connected"); this.resetReconnectAttempts(); }); this.wsClient.on("close", (evt) => { logger.warn( { wsKey: evt.wsKey }, `[binance] WebSocket 连接关闭`, ); // 如果之前是已连接状态,SDK 会自动重连 if (this._connectionState === "connected") { this.setConnectionState("connecting"); } }); // 预热连接(SDK 连接到 spot 公开行情端点) await this.wsClient.connectPublic(); this.setConnectionState("connected"); this.resetReconnectAttempts(); logger.info(`[binance] WebSocket 连接已建立`); } catch (err) { this.setConnectionState("error"); logger.error({ err }, `[binance] 连接失败`); throw err; } } /** * 断开连接并清理资源。 * * 1. 取消所有 WebSocket 订阅 * 2. 关闭 WebSocket 客户端 * 3. Complete 所有 RxJS Subject */ async disconnect(): Promise { logger.info(`[binance] 断开连接...`); try { // 取消所有订阅 if (this.wsClient && this.subscribedTradeSymbols.size > 0) { const topics = [...this.subscribedTradeSymbols].map( (s) => `${s.toLowerCase()}@trade`, ); await this.wsClient.unsubscribe(topics, "main"); } if (this.wsClient && this.subscribedTickerSymbols.size > 0) { const topics = [...this.subscribedTickerSymbols].map( (s) => `${s.toLowerCase()}@ticker`, ); await this.wsClient.unsubscribe(topics, "main"); } if (this.wsClient && this.subscribedOrderbookDepths.size > 0) { for (const [symbol, depth] of this.subscribedOrderbookDepths) { const topic = `${symbol.toLowerCase()}@depth${depth}@100ms`; await this.wsClient.unsubscribe([topic], "main"); } } } catch (err) { logger.warn({ err }, `[binance] 取消订阅时出错(忽略)`); } // 关闭 WS 客户端所有连接(SDK 自动关闭底层 WebSocket) try { this.wsClient?.closeAll(); } catch { // 忽略关闭错误 } // Complete 所有 Subject this.tickerSubject?.complete(); this.tradeSubject?.complete(); for (const subject of this.orderbookSubjects.values()) { subject.complete(); } this.orderbookSubjects.clear(); this.subscribedTickerSymbols.clear(); this.subscribedTradeSymbols.clear(); this.subscribedOrderbookDepths.clear(); this.setConnectionState("disconnected"); logger.info(`[binance] 已断开连接`); } // ============================================================ // 订阅 Ticker(24h 滚动统计) // ============================================================ subscribeTicker(symbols: string[]): Observable { if (!this.tickerSubject) { this.tickerSubject = this.createManagedSubject(); } const newSymbols = symbols.filter( (s) => !this.subscribedTickerSymbols.has(s), ); if (newSymbols.length > 0 && this._connectionState === "connected") { const topics = newSymbols.map( (s) => `${s.toLowerCase()}@ticker`, ); this.wsClient.subscribe(topics, "main").catch((err) => { logger.error({ err, symbols: newSymbols }, `[binance] 订阅 ticker 失败`); }); for (const s of newSymbols) { this.subscribedTickerSymbols.add(s); } logger.info( { count: newSymbols.length, symbols: newSymbols }, `[binance] 订阅 ticker`, ); } return this.tickerSubject.asObservable(); } // ============================================================ // 订阅逐笔成交 // ============================================================ subscribeTrade(symbols: string[]): Observable { if (!this.tradeSubject) { this.tradeSubject = this.createManagedSubject(); } const newSymbols = symbols.filter( (s) => !this.subscribedTradeSymbols.has(s), ); if (newSymbols.length > 0 && this._connectionState === "connected") { const topics = newSymbols.map( (s) => `${s.toLowerCase()}@trade`, ); this.wsClient.subscribe(topics, "main").catch((err) => { logger.error({ err, symbols: newSymbols }, `[binance] 订阅 trade 失败`); }); for (const s of newSymbols) { this.subscribedTradeSymbols.add(s); } logger.info( { count: newSymbols.length, symbols: newSymbols }, `[binance] 订阅 trade`, ); } return this.tradeSubject.asObservable(); } // ============================================================ // 订阅订单簿深度 // ============================================================ subscribeOrderbook(symbol: string, depth: number = 20): Observable { const key = `${symbol}@${depth}`; let subject = this.orderbookSubjects.get(key); if (subject) { return subject.asObservable(); } subject = this.createManagedSubject(); this.orderbookSubjects.set(key, subject); if (this._connectionState === "connected") { const topic = `${symbol.toLowerCase()}@depth${depth}@100ms`; this.wsClient.subscribe([topic], "main").catch((err) => { logger.error({ err, symbol, depth }, `[binance] 订阅 orderbook 失败`); }); this.subscribedOrderbookDepths.set(symbol, depth); logger.info({ symbol, depth }, `[binance] 订阅 orderbook`); } return subject.asObservable(); } // ============================================================ // REST:拉取历史 K 线(补缺失数据 / 回测) // ============================================================ /** * 通过 Binance REST API 拉取历史 K 线。 * * Binance 限制: * - 单次最多 1000 条(默认 500) * - 权重 2(1200 权重/分钟 → 600 次请求/分钟) * - 自动分页逻辑:如果时间跨度超过 limit 条,自动多次请求拼接 * * @param symbol - 交易对(如 BTCUSDT) * @param interval - K 线周期 * @param startTime - 起始时间(Unix ms) * @param endTime - 结束时间(Unix ms) * @param limit - 单次最大条数(默认 500,最大 1000) */ async fetchKlines( symbol: string, interval: KlineInterval, startTime: number, endTime: number, limit: number = 500, ): Promise { const binanceInterval = INTERVAL_TO_BINANCE[interval]; const intervalMs = KLINE_INTERVAL_MS[interval]; const maxLimit = Math.min(limit, 1000); // Binance 硬限制 1000 const allKlines: Kline[] = []; let currentStart = startTime; // 自动分页:如果时间跨度超过 maxLimit 条 K 线,分批拉取 while (currentStart < endTime) { // 速率限制(保守节流) const throttleKey = `${symbol}:${interval}`; const lastFetch = this.lastRestFetch.get(throttleKey) ?? 0; const elapsed = Date.now() - lastFetch; if (elapsed < this.config.restRateLimitMs) { await new Promise((r) => setTimeout(r, this.config.restRateLimitMs - elapsed), ); } this.lastRestFetch.set(throttleKey, Date.now()); try { const rawKlines = await this.restClient.getKlines({ symbol, interval: binanceInterval, startTime: currentStart, endTime, limit: maxLimit, }); if (!rawKlines || rawKlines.length === 0) { break; // 无更多数据 } // 转换 Binance REST K 线 → 本系统标准化 K 线 const converted = rawKlines.map((k) => this.convertRestKline(k, symbol, interval), ); allKlines.push(...converted); // Binance REST K 线格式:[openTime, open, high, low, close, volume, closeTime, ...] // 最后一条的开盘时间 + interval 作为下一批的起点 const lastKline = rawKlines[rawKlines.length - 1]!; const lastOpenTime = (lastKline as number[])[0] as number; currentStart = lastOpenTime + intervalMs; // 如果返回数量 < limit,说明已拉完 if (rawKlines.length < maxLimit) { break; } } catch (err) { logger.error( { err, symbol, interval, currentStart, endTime }, `[binance] fetchKlines 失败`, ); throw err; } } logger.debug( { symbol, interval, count: allKlines.length, startTime, endTime }, `[binance] fetchKlines 完成`, ); return allKlines; } // ============================================================ // REST:拉取交易对元数据 // ============================================================ /** * 从 Binance 获取所有现货交易对信息,转换为本系统 MarketInfo 格式。 * * 用于自动注册到 trading_pairs 表,避免手动配置。 */ async fetchMarkets(): Promise { logger.info(`[binance] 拉取交易对信息...`); try { const exchangeInfo = await this.restClient.getExchangeInfo(); const markets: MarketInfo[] = []; for (const symbolInfo of exchangeInfo.symbols) { // 仅保留状态为 TRADING 的现货交易对 if (symbolInfo.status !== "TRADING") continue; const filters = symbolInfo.filters; // 从 filters 中提取交易规则 let tickSize: string | undefined; let stepSize: string | undefined; let minQty: string | undefined; let minNotional: string | undefined; for (const filter of filters) { switch (filter.filterType) { case "PRICE_FILTER": tickSize = (filter as { tickSize: string }).tickSize; break; case "LOT_SIZE": stepSize = (filter as { stepSize: string }).stepSize; minQty = (filter as { minQty: string }).minQty; break; case "MIN_NOTIONAL": case "NOTIONAL": minNotional = (filter as { minNotional: string }).minNotional; break; } } markets.push({ symbol: symbolInfo.symbol, baseAsset: symbolInfo.baseAsset, quoteAsset: symbolInfo.quoteAsset, pricePrecision: symbolInfo.quoteAssetPrecision, quantityPrecision: symbolInfo.baseAssetPrecision, minQty: minQty ? parseFloat(minQty) : undefined, stepSize: stepSize ? parseFloat(stepSize) : undefined, minNotional: minNotional ? parseFloat(minNotional) : undefined, }); } logger.info( { count: markets.length }, `[binance] 交易对信息拉取完成`, ); return markets; } catch (err) { logger.error({ err }, `[binance] fetchMarkets 失败`); throw err; } } // ============================================================ // 内部:formattedMessage 事件分发 // ============================================================ /** * Binance SDK formattedMessage 回调。 * * SDK 已将原始 WebSocket JSON 解析为类型化事件对象。 * WsFormattedMessage 是复杂联合类型(含单事件 + 事件数组), * TypeScript 的判别联合在此处不够精确。内部使用 `as any` 绕过 * 联合类型限制,按 eventType 字符串运行时路由。 */ private onFormattedMessage(msg: WsFormattedMessage): void { try { // 数组类型(如 !ticker@arr → WsMessage24hrTickerFormatted[]) if (Array.isArray(msg)) { for (const item of msg) { this.routeByEventType(item as unknown as Record); } return; } this.routeByEventType(msg as unknown as Record); } catch (err) { const raw = msg as unknown as Record; const eventType = String(raw["eventType"] ?? "unknown"); logger.error( { err, eventType }, `[binance] 处理 formattedMessage 时出错`, ); } } /** * 按 eventType 运行时路由到对应 Subject。 * * 此处使用 unknown → Record 转换,因为 WsFormattedMessage * 联合类型包含数组成员导致无法直接访问 eventType。 */ private routeByEventType(raw: Record): void { const eventType = String(raw["eventType"] ?? ""); if (!eventType) return; switch (eventType) { case "24hrTicker": case "!ticker@arr": this.handleTickerMessage( raw as unknown as WsMessage24hrTickerFormatted, ); break; case "trade": this.handleTradeMessage( raw as unknown as WsMessageTradeFormatted, ); break; case "bookTicker": this.handleBookTickerMessage( raw as unknown as WsMessageBookTickerEventFormatted, ); break; case "partialBookDepth": this.handleOrderbookMessage( raw as unknown as WsMessagePartialBookDepthEventFormatted, ); break; case "kline": // K 线事件不在 adapter 层分发,由 pipeline 的 KlineSynthesizer 处理 break; default: // 忽略其他事件类型(用户数据流、账户更新等) break; } } // ---------------------------------------------------------- // 事件转换器:Binance → 本系统标准化类型 // ---------------------------------------------------------- /** 24h Ticker → Ticker */ private handleTickerMessage(msg: WsMessage24hrTickerFormatted): void { if (!this.tickerSubject || this.tickerSubject.closed) return; const ticker: Ticker = { exchange: "binance", symbol: msg.symbol, lastPrice: msg.currentClose, openPrice: msg.open, highPrice: msg.high, lowPrice: msg.low, volume: msg.baseAssetVolume, quoteVolume: msg.quoteAssetVolume, priceChange: msg.priceChange, priceChangePercent: msg.priceChangePercent, bidPrice: msg.bestBid, bidQty: msg.bestBidQuantity, askPrice: msg.bestAskPrice, askQty: msg.bestAskQuantity, eventTime: msg.eventTime, closeTime: msg.closeTime, }; this.tickerSubject.next(ticker); } /** 逐笔成交 → Trade */ private handleTradeMessage(msg: WsMessageTradeFormatted): void { if (!this.tradeSubject || this.tradeSubject.closed) return; const trade: Trade = { exchange: "binance", symbol: msg.symbol, price: msg.price, amount: msg.quantity, quoteAmount: msg.price * msg.quantity, timestamp: msg.time, isBuyerMaker: msg.maker, tradeId: String(msg.tradeId), }; this.tradeSubject.next(trade); } /** BookTicker → Ticker(精简版,仅有最佳买卖价) */ private handleBookTickerMessage(msg: WsMessageBookTickerEventFormatted): void { // BookTicker 是 Ticker 的精简版,仅更新最佳买卖价 // 如果有 tickerSubject,将其作为轻量 Ticker 推送 if (!this.tickerSubject || this.tickerSubject.closed) return; const ticker: Ticker = { exchange: "binance", symbol: msg.symbol, lastPrice: 0, // bookTicker 不含最新价 openPrice: 0, highPrice: 0, lowPrice: 0, volume: 0, quoteVolume: 0, priceChange: 0, priceChangePercent: 0, bidPrice: msg.bidPrice, bidQty: msg.bidQty, askPrice: msg.askPrice, askQty: msg.askQty, eventTime: msg.eventTime, closeTime: 0, }; this.tickerSubject.next(ticker); } /** 订单簿深度快照 → OrderBook */ private handleOrderbookMessage(msg: WsMessagePartialBookDepthEventFormatted): void { // partialBookDepth 不含 symbol 字段(取决于 SDK 版本), // 从 stream 名称或上下文推断 symbol。此处假设 SDK 已填充 symbol。 const symbol = (msg as WsMessagePartialBookDepthEventFormatted & { symbol?: string }).symbol; if (!symbol) { logger.warn(`[binance] 收到无 symbol 的 orderbook 消息,丢弃`); return; } // 查找匹配的 orderbook Subject(遍历所有已订阅 depth) for (const [key, subject] of this.orderbookSubjects) { const [subscribedSymbol] = key.split("@"); if ( subscribedSymbol?.toUpperCase() === symbol.toUpperCase() && !subject.closed ) { const orderbook: OrderBook = { exchange: "binance", symbol: symbol.toUpperCase(), bids: msg.bids.map( ([price, qty]) => [parseFloat(String(price)), parseFloat(String(qty))] as [number, number], ), asks: msg.asks.map( ([price, qty]) => [parseFloat(String(price)), parseFloat(String(qty))] as [number, number], ), lastUpdateId: msg.lastUpdateId, eventTime: Date.now(), // partialBookDepth 不含 eventTime }; subject.next(orderbook); return; } } } // ============================================================ // 内部:REST K 线格式转换 // ============================================================ /** * 将 Binance REST K 线数组(元组)转换为本系统 Kline 对象。 * * Binance REST K 线格式: * [ * 0: openTime (ms), * 1: open (string), * 2: high (string), * 3: low (string), * 4: close (string), * 5: volume (string), * 6: closeTime (ms), * 7: quoteVolume (string), * 8: tradeCount (number), * 9: takerBuyBaseVol (string), * 10: takerBuyQuoteVol (string), * 11: ignore (string) * ] */ private convertRestKline( raw: BinanceRestKline, symbol: string, interval: KlineInterval, ): Kline { // BinanceRestKline 是元组类型,按位置索引 const arr = raw as unknown as [ number, // 0: openTime string, // 1: open string, // 2: high string, // 3: low string, // 4: close string, // 5: volume number, // 6: closeTime string, // 7: quoteVolume number, // 8: tradeCount string, // 9: takerBuyBaseVol string, // 10: takerBuyQuoteVol string, // 11: ignore ]; return { exchange: "binance", symbol, interval, openTime: arr[0], closeTime: arr[6], open: parseFloat(arr[1]), high: parseFloat(arr[2]), low: parseFloat(arr[3]), close: parseFloat(arr[4]), volume: parseFloat(arr[5]), quoteVolume: parseFloat(arr[7]), takerBuyBaseVol: parseFloat(arr[9]), takerBuyQuoteVol: parseFloat(arr[10]), tradeCount: arr[8], isClosed: true, // REST 返回的 K 线都是已闭合的 }; } } export default BinanceAdapter;