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 初始化脚本
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
// ============================================================
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user