Files
trade/data/exchanges/binance.ts
T
Rekey e91cad79e6 feat(data): 实现配置表 CRUD 与 Schema 初始化拆分
- 新增 data/db/ 数据库访问层:pool 管理、类型定义、Zod 校验、参数化 SQL 查询
- 新增 data/db/config-crud.ts:MonitoredSymbolsRepo / ExchangeConfigRepo / AppConfigRepo 三个 CRUD 服务类
- 新增 data/config.ts:中心化配置模块,零依赖 .env 解析 + Zod 校验
- 新增 data/schema/:klines.sql + config.sql 参考 DDL
- 新增 data/exchanges/:交易所类型定义与 Binance WebSocket 封装
- 新增 data/run/:交易所连接启动入口
- 重构 data/init-db/:001_init.sql 仅保留 TimescaleDB + klines,配置表拆分至 002_config.sql
- 更新 docker-compose.yml:挂载 init-db 初始化脚本
2026-06-07 20:46:35 +08:00

396 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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<OHLCVCandle[]>;
close(): Promise<void>;
}
// ============================================================
// 类型声明:为 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<string, unknown>;
// ---- 运行时状态 ----
private exchange: IProExchange | null = null;
private state: WsConnectionState = "idle";
private abortController: AbortController | null = null;
/** 每个 symbol 一条 watch 循环 */
private watchTasks: Promise<void>[] = [];
/** 已成功启动监听的 symbol 集合 */
private readonly readySymbols = new Set<string>();
// ============================================================
// 构造器:传入参数,自动启动 WebSocket 监听
// ============================================================
/**
* @param config.exchangeId - 交易所 IDbinance / 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<void> {
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<void> {
// 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<void> {
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<string, unknown>;
const proNamespace = ccxtAny.pro as
| Record<string, new (opts?: Record<string, unknown>) => 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<void> {
const signal = this.abortController!.signal;
// 带退避的重连循环
let consecutiveErrors = 0;
const maxBackoff = 30000; // 最大退避 30s
while (!signal.aborted) {
try {
// 检查是否已取消
if (signal.aborted) break;
// watchOHLCV 返回 Promise<OHLCV[]>,在新数据到达时 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<void> {
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<void> {
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);
}
}
}