e91cad79e6
- 新增 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 初始化脚本
396 lines
13 KiB
TypeScript
396 lines
13 KiB
TypeScript
// ============================================================
|
||
// 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 - 交易所 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<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);
|
||
}
|
||
}
|
||
}
|