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:
Rekey
2026-06-07 20:46:35 +08:00
parent 10e13ae8da
commit e91cad79e6
18 changed files with 8560 additions and 5 deletions
+395
View File
@@ -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 - 交易所 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);
}
}
}
+45
View File
@@ -0,0 +1,45 @@
// ============================================================
// exchanges/types.ts — WebSocket 事件数据类型
// ============================================================
/** 由 WebSocket 推送的单根 K 线数据 */
export interface KlineWsData {
/** 交易所标识 */
exchange: string;
/** 交易对,如 BTCUSDT */
symbol: string;
/** K 线周期,如 1m / 1h / 1d */
interval: string;
/** K 线开盘时间(Unix 毫秒时间戳) */
time: number;
/** 开盘价 */
open: number;
/** 最高价 */
high: number;
/** 最低价 */
low: number;
/** 收盘价 */
close: number;
/** 成交量(基准币种) */
volume: number;
}
/** ExchangeWs 构造参数 */
export interface ExchangeWsConfig {
/** 交易所 ID,ccxt 支持的所有交易所标识 */
exchangeId: string;
/** 要订阅的交易对列表,如 ['BTCUSDT', 'ETHUSDT'] */
symbols: string[];
/** K 线周期,默认 '1m' */
interval?: string;
/** 传递给 ccxt.pro 交易所实例的额外选项(如 agent、apiKey 等) */
ccxtOptions?: Record<string, unknown>;
}
/** ExchangeWs 连接状态 */
export type WsConnectionState =
| "idle" // 尚未启动
| "connecting" // 正在连接 WebSocket
| "connected" // 已连接,正在接收数据
| "disconnected" // 已断开
| "error"; // 错误状态