// ============================================================ // exchanges/binance.ts — 通用交易所 WebSocket 行情采集类 // ============================================================ // 基于 ccxt.pro 实现,不限于 Binance,支持任意 ccxt 支持的交易所。 // 构造即启动:传入交易所 ID + 交易对列表,自动开始 WebSocket 监听。 // // 使用方式: // import { ExchangeWs } from "./exchanges/binance"; // const ws = new ExchangeWs({ // exchangeId: "binance", // symbols: ["BTCUSDT", "ETHUSDT"], // interval: "1m", // }); // ws.on("kline", (data) => console.log(data)); // ============================================================ import ccxt from "ccxt"; import { EventEmitter } from "node:events"; import type { KlineWsData, ExchangeWsConfig, WsConnectionState, } from "./types"; // ============================================================ // 内部类型:ccxt pro 交易所实例的最小接口 // ============================================================ // 避免直接依赖 ccxt 内部类型(不同版本导出签名差异大), // 仅声明本类实际调用的方法签名。 /** ccxt OHLCV 数组:[timestamp_ms, open, high, low, close, volume] */ type OHLCVCandle = [number, number, number, number, number, number]; /** ccxt.pro 交易所实例的最小接口 */ interface IProExchange { watchOHLCV(symbol: string, timeframe: string): Promise; close(): Promise; } // ============================================================ // 类型声明:为 EventEmitter 添加严格的事件签名 // ============================================================ export declare interface ExchangeWs { /** 新 K 线数据到达(含实时更新和闭合 K 线) */ on(event: "kline", listener: (data: KlineWsData) => void): this; /** WebSocket 错误(非致命,单 symbol 出错不影响其他) */ on(event: "error", listener: (error: ExchangeWsError) => void): this; /** 连接状态变更 */ on( event: "stateChange", listener: (state: WsConnectionState, exchangeId: string) => void, ): this; /** 单个 symbol 的 watch 循环已启动 */ on( event: "symbolReady", listener: (symbol: string, interval: string) => void, ): this; /** 所有 symbols 已进入监听状态 */ on(event: "ready", listener: () => void): this; } /** 带 symbol 上下文的错误类型 */ export class ExchangeWsError extends Error { symbol: string; exchangeId: string; constructor(message: string, exchangeId: string, symbol: string) { super(message); this.name = "ExchangeWsError"; this.exchangeId = exchangeId; this.symbol = symbol; } } // ============================================================ // ExchangeWs — 主类 // ============================================================ export class ExchangeWs extends EventEmitter { // ---- 配置 ---- private readonly exchangeId: string; private readonly symbols: string[]; private readonly interval: string; private readonly ccxtOptions: Record; // ---- 运行时状态 ---- private exchange: IProExchange | null = null; private state: WsConnectionState = "idle"; private abortController: AbortController | null = null; /** 每个 symbol 一条 watch 循环 */ private watchTasks: Promise[] = []; /** 已成功启动监听的 symbol 集合 */ private readonly readySymbols = new Set(); // ============================================================ // 构造器:传入参数,自动启动 WebSocket 监听 // ============================================================ /** * @param config.exchangeId - 交易所 ID(binance / okx / bybit / …) * @param config.symbols - 要订阅的交易对列表 * @param config.interval - K 线周期,默认 '1m' * @param config.ccxtOptions - 传递给 ccxt.pro 交易所构造器的额外选项 * * 构造完成后 WebSocket 连接立即在后台启动, * 监听 'ready' 事件确认全部就绪,或 'kline' 事件接收数据。 */ constructor(config: ExchangeWsConfig) { super(); // 参数校验 if (!config.symbols || config.symbols.length === 0) { throw new Error("symbols 不能为空"); } this.exchangeId = config.exchangeId; this.symbols = [...config.symbols]; // 防御性拷贝 this.interval = config.interval ?? "1m"; this.ccxtOptions = config.ccxtOptions ?? {}; // 构造即启动(fire-and-forget,错误通过 'error' 事件抛出) this.start().catch((err) => { this.emit( "error", new ExchangeWsError( `启动失败: ${(err as Error).message}`, this.exchangeId, "ALL", ), ); }); } // ============================================================ // 公开方法 // ============================================================ /** 获取当前连接状态 */ getState(): WsConnectionState { return this.state; } /** 获取已就绪的 symbol 列表 */ getReadySymbols(): string[] { return [...this.readySymbols]; } /** * 动态添加交易对(已监听的 symbol 会被忽略) * 可在运行时增删监控标的,无需重启整个连接 */ async addSymbols(newSymbols: string[]): Promise { const toAdd = newSymbols.filter((s) => !this.readySymbols.has(s)); if (toAdd.length === 0) { return; }; for (const symbol of toAdd) { this.symbols.push(symbol); const task = this.watchSymbol(symbol); this.watchTasks.push(task); } } /** * 停止 WebSocket 监听并释放资源 * 调用后实例不可复用,需重新 new */ async close(): Promise { // 1. 取消所有 watch 循环 this.abortController?.abort(); // 2. 等待所有 watch 循环退出 await Promise.allSettled(this.watchTasks); // 3. 关闭 ccxt 交易所连接 if (this.exchange) { try { await this.exchange.close(); } catch { // 忽略关闭时的错误 } this.exchange = null; } this.setState("disconnected"); this.removeAllListeners(); } // ============================================================ // 内部方法 // ============================================================ /** 异步启动:初始化 ccxt 实例并为每个 symbol 启动 watch 循环 */ private async start(): Promise { this.abortController = new AbortController(); this.setState("connecting"); // 1. 创建 ccxt.pro 交易所实例 this.exchange = this.createExchange(); // 2. 为每个 symbol 启动独立的 watch 循环 this.watchTasks = this.symbols.map((symbol) => this.watchSymbol(symbol)); // 3. 等待任意 symbol 就绪(或全部失败) try { await this.waitForAnyReady(); this.setState("connected"); this.emit("ready"); } catch { this.setState("error"); this.emit( "error", new ExchangeWsError("所有 symbol 连接失败", this.exchangeId, "ALL"), ); } } /** 创建 ccxt.pro 交易所实例 */ private createExchange(): IProExchange { // 动态获取 ccxt.pro[exchangeId] 构造器 const ccxtAny = ccxt as unknown as Record; const proNamespace = ccxtAny.pro as | Record) => IProExchange> | undefined; if (!proNamespace || typeof proNamespace !== "object") { throw new Error( "ccxt.pro 不可用,请确认 ccxt 版本 >= 4.0 且已安装 pro 支持", ); } const ProExchange = proNamespace[this.exchangeId]; if (!ProExchange) { throw new Error( `不支持的交易所: ${this.exchangeId},可用: ${Object.keys(proNamespace).join(", ")}`, ); } return new ProExchange({ enableRateLimit: true, ...this.ccxtOptions, }); } /** 单个 symbol 的 watch 循环 */ private async watchSymbol(symbol: string): Promise { const signal = this.abortController!.signal; // 带退避的重连循环 let consecutiveErrors = 0; const maxBackoff = 30000; // 最大退避 30s while (!signal.aborted) { try { // 检查是否已取消 if (signal.aborted) break; // watchOHLCV 返回 Promise,在新数据到达时 resolve // ccxt 返回格式:[timestamp_ms, open, high, low, close, volume] const candles = await this.exchange!.watchOHLCV( symbol, this.interval, ); // 重置错误计数(成功获取数据) consecutiveErrors = 0; // 标记该 symbol 已就绪 if (!this.readySymbols.has(symbol)) { this.readySymbols.add(symbol); this.emit("symbolReady", symbol, this.interval); } // 处理返回的 K 线数据 this.processCandles(symbol, candles); } catch (err) { if (signal.aborted) break; consecutiveErrors++; const delay = Math.min(1000 * 2 ** consecutiveErrors, maxBackoff); this.emit( "error", new ExchangeWsError( `[${symbol}] ${(err as Error).message},${delay / 1000}s 后重试 (第 ${consecutiveErrors} 次)`, this.exchangeId, symbol, ), ); // 指数退避等待(可被 abort 中断) await this.sleep(delay, signal); } } } /** 处理 ccxt watchOHLCV 返回的原始数据 */ private processCandles(symbol: string, candles: OHLCVCandle[]): void { // candles 是完整的 OHLCV 数组,最后一条是最新数据 // ccxt 会返回增量更新,通常只包含新增的 candle for (const candle of candles) { // ccxt OHLCV 格式:[timestamp_ms, open, high, low, close, volume] if (!Array.isArray(candle) || candle.length < 6) { continue; }; const data: KlineWsData = { exchange: this.exchangeId, symbol, interval: this.interval, time: candle[0], open: candle[1], high: candle[2], low: candle[3], close: candle[4], volume: candle[5], }; this.emit("kline", data); } } /** 等待至少一个 symbol 就绪(或超时/全部失败) */ private async waitForAnyReady(): Promise { return new Promise((resolve, reject) => { let resolved = false; const onReady = () => { if (!resolved) { resolved = true; cleanup(); resolve(); } }; const onError = (err: ExchangeWsError) => { // 仅在所有 symbol 都失败时 reject if (!resolved && err.symbol === "ALL") { resolved = true; cleanup(); reject(err); } }; const cleanup = () => { this.off("symbolReady", onReady); this.off("error", onError); }; this.once("symbolReady", onReady); this.once("error", onError); // 超时保护(30 秒) setTimeout(() => { if (!resolved) { resolved = true; cleanup(); reject( new ExchangeWsError("连接超时(30s)", this.exchangeId, "ALL"), ); } }, 30000); }); } /** 带 AbortSignal 的 sleep */ private sleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve) => { if (signal.aborted) { resolve(); return; } const timer = setTimeout(resolve, ms); signal.addEventListener( "abort", () => { clearTimeout(timer); resolve(); }, { once: true }, ); }); } /** 更新状态并发出事件 */ private setState(newState: WsConnectionState): void { if (this.state !== newState) { this.state = newState; this.emit("stateChange", newState, this.exchangeId); } } }