feat(data): 实现 Binance WebSocket 适配器与架构重构
- 新增 exchanges/ 模块:MarketDataFeed 统一接口、BaseExchangeAdapter 抽象基类、 BinanceAdapter 完整实现(WebSocket + REST) - WebSocket 层基于 binance 官方 SDK 的 WebsocketClient,自动多路复用与断线重连 - REST 层使用 MainClient(Spot),实现 fetchKlines 自动分页补拉 + fetchMarkets 元数据解析 - 数据标准化:Ticker/Trade/OrderBook/Kline 类型定义与 Binance 原生格式互转 - 引入 RxJS Subject 作为统一事件流管道,按 eventType 运行时路由分发 - 重构 config/:YAML 驱动配置加载 + 零依赖运行时校验(fail-fast) - 重构 db/:TypeORM DataSource 配置 + TimescaleDB K 线 Hypertable 实体 - 新增 utils/logger.ts:Pino 结构化日志(开发环境 pino-pretty 彩色输出) - 新增 env.yaml 作为 TS/Python 共享的统一环境配置源 - 删除旧版手写 SQL schema 与散落配置文件,收敛到 TypeORM 实体管理 - 安装 rxjs@7.8.2 依赖
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// ============================================================
|
||||
// config.ts — 中心化配置模块(YAML 驱动)
|
||||
// ============================================================
|
||||
// 职责:
|
||||
// 1. 从项目根目录 env.yaml 加载配置
|
||||
// 2. 使用 validateConfig() 校验并类型收窄
|
||||
// 3. 导出按职责分组的强类型配置对象(pgsql / redis / logging)
|
||||
//
|
||||
// 使用方式:
|
||||
// import { pgsql, redis, logging } from "./config";
|
||||
// const ds = new DataSource({ ...pgsql });
|
||||
// const redisClient = new Redis(redis.url);
|
||||
//
|
||||
// 配置文件位置:<project_root>/env.yaml
|
||||
// TypeScript / Python 模块共享同一份配置。
|
||||
// ============================================================
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { validateConfig, type EnvConfig } from "./validators";
|
||||
|
||||
// ============================================================
|
||||
// 1. 定位并读取 env.yaml
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 计算项目根目录的绝对路径。
|
||||
* data/config.ts → data/ → <project_root>/
|
||||
*
|
||||
* 兼容 ESM(无 __dirname)和 Bun 运行时。
|
||||
*/
|
||||
function getProjectRoot(): string {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
// config/index.ts → config/ → data/ → <project_root>
|
||||
return resolve(__dirname, "../..");
|
||||
}
|
||||
|
||||
/**
|
||||
* 从项目根目录读取 env.yaml 并解析为原始对象。
|
||||
* 文件不存在时抛出明确错误,不做静默降级。
|
||||
*/
|
||||
function loadYamlConfig(): unknown {
|
||||
const root = getProjectRoot();
|
||||
const yamlPath = resolve(root, "env.yaml");
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(yamlPath, "utf-8");
|
||||
} catch {
|
||||
throw new Error(
|
||||
`[config] 无法读取配置文件: ${yamlPath}\n` +
|
||||
`请确保项目根目录存在 env.yaml(可参考 data/.env.example 的结构)。`,
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseYaml(content);
|
||||
if (parsed === null || parsed === undefined) {
|
||||
throw new Error(`[config] env.yaml 解析结果为空: ${yamlPath}`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. 加载 & 校验
|
||||
// ============================================================
|
||||
|
||||
/** 经校验后的 env.yaml 配置(强类型) */
|
||||
const rawConfig: EnvConfig = (() => {
|
||||
const raw = loadYamlConfig();
|
||||
return validateConfig(raw);
|
||||
})();
|
||||
|
||||
// ============================================================
|
||||
// 3. 按职责分组的导出配置对象
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* PostgreSQL / TimescaleDB 连接配置
|
||||
*
|
||||
* 连接池参数(max / idleTimeoutMillis / connectionTimeoutMillis)
|
||||
* 为硬编码常量,不在 env.yaml 中暴露,避免误调导致连接耗尽。
|
||||
*/
|
||||
export const pgsql = {
|
||||
host: rawConfig.db.host,
|
||||
port: rawConfig.db.port,
|
||||
database: rawConfig.db.name,
|
||||
user: rawConfig.db.user,
|
||||
password: rawConfig.db.password,
|
||||
/** pg.Pool 连接上限,避免连接数暴涨 */
|
||||
max: 20,
|
||||
/** 连接空闲超时(毫秒) */
|
||||
idleTimeoutMillis: 30000,
|
||||
/** 连接获取超时(毫秒) */
|
||||
connectionTimeoutMillis: 5000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Redis 连接与发布配置
|
||||
*
|
||||
* channelPrefix / retryDelayBaseMs / maxRetries 为硬编码常量,
|
||||
* 跨模块保持一致,不需要通过配置文件修改。
|
||||
*/
|
||||
export const redis = {
|
||||
/** Redis 连接 URL(ioredis 可直接使用) */
|
||||
url: rawConfig.redis.url,
|
||||
/** 是否启用 Pub/Sub 发布行情数据(开发环境可关闭以节省资源) */
|
||||
publishEnabled: rawConfig.redis.publish_enabled,
|
||||
/** 频道前缀,避免多环境 key 冲突 */
|
||||
channelPrefix: "trade",
|
||||
/** 重连策略:指数退避基数(毫秒) */
|
||||
retryDelayBaseMs: 1000,
|
||||
/** 最大重试次数 */
|
||||
maxRetries: 10,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 日志配置
|
||||
*
|
||||
* pretty 由 NODE_ENV 自动推导,不在 env.yaml 中独立配置。
|
||||
*/
|
||||
export const logging = {
|
||||
/** 日志级别:trace / debug / info / warn / error / fatal */
|
||||
level: rawConfig.logging.level,
|
||||
/** 运行环境(production 时 pino 输出 JSON 便于日志采集) */
|
||||
nodeEnv: rawConfig.logging.node_env,
|
||||
/** 是否启用 pino-pretty(开发环境友好输出) */
|
||||
pretty: rawConfig.logging.node_env === "development",
|
||||
} as const;
|
||||
|
||||
// ============================================================
|
||||
// 4. 工具:运行时打印配置概要(不含敏感信息)
|
||||
// ============================================================
|
||||
|
||||
/** 打印脱敏后的配置概要,便于启动排查 */
|
||||
export function printConfigSummary(): void {
|
||||
const summary = {
|
||||
projectRoot: getProjectRoot(),
|
||||
pgsql: {
|
||||
host: pgsql.host,
|
||||
port: pgsql.port,
|
||||
database: pgsql.database,
|
||||
user: pgsql.user,
|
||||
password: "***",
|
||||
},
|
||||
redis: {
|
||||
url: redis.url.replace(/\/\/.*@/, "//***@"), // 隐藏密码
|
||||
publishEnabled: redis.publishEnabled,
|
||||
},
|
||||
logging: {
|
||||
level: logging.level,
|
||||
nodeEnv: logging.nodeEnv,
|
||||
pretty: logging.pretty,
|
||||
},
|
||||
};
|
||||
console.log("[config] 配置概要:", JSON.stringify(summary, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// ============================================================
|
||||
// validators.ts — env.yaml 配置类型定义与运行时校验
|
||||
// ============================================================
|
||||
// 职责:
|
||||
// 1. 定义 env.yaml 的 TypeScript 接口
|
||||
// 2. 提供运行时校验函数(零依赖,手动实现)
|
||||
// 3. 为 config.ts 提供类型安全的配置读取
|
||||
//
|
||||
// 使用方式:
|
||||
// import { validateConfig, type EnvConfig } from "./db/validators";
|
||||
// const raw = validateConfig(parsedYaml);
|
||||
// ============================================================
|
||||
|
||||
/** env.yaml 顶层结构 */
|
||||
export interface EnvConfig {
|
||||
db: DbConfig;
|
||||
redis: RedisConfig;
|
||||
logging: LoggingConfig;
|
||||
}
|
||||
|
||||
export interface DbConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
name: string;
|
||||
user: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RedisConfig {
|
||||
url: string;
|
||||
publish_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface LoggingConfig {
|
||||
level: "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
||||
node_env: "development" | "production" | "test";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 运行时校验(零依赖)
|
||||
// ============================================================
|
||||
|
||||
const VALID_LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] as const;
|
||||
const VALID_NODE_ENVS = ["development", "production", "test"] as const;
|
||||
|
||||
/**
|
||||
* 校验并返回类型安全的配置对象。
|
||||
* 校验失败时抛出明确错误信息,遵循 fail-fast 原则。
|
||||
*/
|
||||
export function validateConfig(raw: unknown): EnvConfig {
|
||||
if (typeof raw !== "object" || raw === null) {
|
||||
throw new Error(`[config] env.yaml 顶层必须为 object,实际: ${typeof raw}`);
|
||||
}
|
||||
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
// --- db ---
|
||||
const db = obj["db"];
|
||||
if (typeof db !== "object" || db === null) {
|
||||
throw new Error("[config] env.yaml 缺少 db 配置段");
|
||||
}
|
||||
const dbObj = db as Record<string, unknown>;
|
||||
|
||||
const dbHost = assertString(dbObj["host"], "db.host");
|
||||
const dbPort = assertPort(dbObj["port"], "db.port");
|
||||
const dbName = assertString(dbObj["name"], "db.name");
|
||||
const dbUser = assertString(dbObj["user"], "db.user");
|
||||
const dbPassword = assertString(dbObj["password"], "db.password");
|
||||
|
||||
// --- redis ---
|
||||
const redis = obj["redis"];
|
||||
if (typeof redis !== "object" || redis === null) {
|
||||
throw new Error("[config] env.yaml 缺少 redis 配置段");
|
||||
}
|
||||
const redisObj = redis as Record<string, unknown>;
|
||||
|
||||
const redisUrl = assertString(redisObj["url"], "redis.url");
|
||||
const redisPublishEnabled = assertBoolean(redisObj["publish_enabled"], "redis.publish_enabled");
|
||||
|
||||
// --- logging ---
|
||||
const logging = obj["logging"];
|
||||
if (typeof logging !== "object" || logging === null) {
|
||||
throw new Error("[config] env.yaml 缺少 logging 配置段");
|
||||
}
|
||||
const logObj = logging as Record<string, unknown>;
|
||||
|
||||
const logLevel = assertEnum(logObj["level"], VALID_LOG_LEVELS, "logging.level");
|
||||
const nodeEnv = assertEnum(logObj["node_env"], VALID_NODE_ENVS, "logging.node_env");
|
||||
|
||||
return {
|
||||
db: {
|
||||
host: dbHost,
|
||||
port: dbPort,
|
||||
name: dbName,
|
||||
user: dbUser,
|
||||
password: dbPassword,
|
||||
},
|
||||
redis: {
|
||||
url: redisUrl,
|
||||
publish_enabled: redisPublishEnabled,
|
||||
},
|
||||
logging: {
|
||||
level: logLevel,
|
||||
node_env: nodeEnv,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助校验函数
|
||||
// ============================================================
|
||||
|
||||
function assertString(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw new Error(`[config] ${path} 必须为非空字符串,实际: ${JSON.stringify(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertPort(value: unknown, path: string): number {
|
||||
if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && /^\d+$/.test(value)) {
|
||||
const n = parseInt(value, 10);
|
||||
if (n > 0 && n <= 65535) return n;
|
||||
}
|
||||
throw new Error(`[config] ${path} 必须为有效端口号 (1-65535),实际: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
function assertBoolean(value: unknown, path: string): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === "true" || value === "false") return value === "true";
|
||||
throw new Error(`[config] ${path} 必须为 boolean,实际: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
function assertEnum<T extends readonly string[]>(
|
||||
value: unknown,
|
||||
allowed: T,
|
||||
path: string,
|
||||
): T[number] {
|
||||
const s = String(value);
|
||||
if ((allowed as readonly string[]).includes(s)) {
|
||||
return s as T[number];
|
||||
}
|
||||
throw new Error(
|
||||
`[config] ${path} 必须为 ${allowed.join(" | ")} 之一,实际: ${JSON.stringify(value)}`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user