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 初始化脚本
246 lines
7.1 KiB
TypeScript
246 lines
7.1 KiB
TypeScript
// ============================================================
|
||
// schema/validators.ts — Zod 运行时校验 Schema
|
||
// ============================================================
|
||
// 用途:
|
||
// 1. WebSocket 行情数据到达后校验字段完整性再入库
|
||
// 2. 配置文件 / 环境变量加载后类型收窄
|
||
// 3. API 输入参数校验
|
||
//
|
||
// 依赖:zod ^4.x(已包含在 data/package.json)
|
||
// ============================================================
|
||
|
||
import { z } from "zod";
|
||
|
||
// ============================================================
|
||
// 基础标量 Schema
|
||
// ============================================================
|
||
|
||
/** 交易所枚举 */
|
||
export const ExchangeSchema = z.enum(["binance", "okx", "bybit"]);
|
||
export type Exchange = z.infer<typeof ExchangeSchema>;
|
||
|
||
/** K 线周期枚举 */
|
||
export const KlineIntervalSchema = z.enum([
|
||
"1m",
|
||
"5m",
|
||
"15m",
|
||
"1h",
|
||
"4h",
|
||
"1d",
|
||
]);
|
||
export type KlineInterval = z.infer<typeof KlineIntervalSchema>;
|
||
|
||
/** 日志级别 */
|
||
export const LogLevelSchema = z.enum([
|
||
"trace",
|
||
"debug",
|
||
"info",
|
||
"warn",
|
||
"error",
|
||
"fatal",
|
||
]);
|
||
|
||
/** 交易对格式:大写字母 + 大写字母(如 BTCUSDT),3-12 字符 */
|
||
export const SymbolSchema = z
|
||
.string()
|
||
.regex(/^[A-Z0-9]{4,14}$/, "交易对格式无效,示例:BTCUSDT");
|
||
|
||
/**
|
||
* NUMERIC(20,8) 数值字符串
|
||
* pg 驱动默认以字符串返回 NUMERIC 以保留精度
|
||
*/
|
||
export const NumericStringSchema = z
|
||
.string()
|
||
.regex(/^-?\d+(\.\d+)?$/, "期望 NUMERIC 字符串");
|
||
|
||
// ============================================================
|
||
// 1. Kline 数据校验 — klines 表
|
||
// ============================================================
|
||
|
||
/** WebSocket 原始 OHLCV 消息校验(单条 K 线,入库前) */
|
||
export const KlineRawSchema = z.object({
|
||
/** K 线开盘时间(UTC),Unix 毫秒时间戳 */
|
||
time: z.number().int().positive(),
|
||
/** 交易所 */
|
||
exchange: ExchangeSchema,
|
||
/** 交易对 */
|
||
symbol: SymbolSchema,
|
||
/** 周期 */
|
||
interval: KlineIntervalSchema,
|
||
/** 开盘价 */
|
||
open: NumericStringSchema,
|
||
/** 最高价 */
|
||
high: NumericStringSchema,
|
||
/** 最低价 */
|
||
low: NumericStringSchema,
|
||
/** 收盘价 */
|
||
close: NumericStringSchema,
|
||
/** 成交量 */
|
||
volume: NumericStringSchema,
|
||
/** 成交额(可选) */
|
||
quote_volume: NumericStringSchema.optional().default("0"),
|
||
/** 主动买入量(可选) */
|
||
taker_buy_base_vol: NumericStringSchema.optional().default("0"),
|
||
/** 主动买入额(可选) */
|
||
taker_buy_quote_vol: NumericStringSchema.optional().default("0"),
|
||
/** 成交笔数(可选) */
|
||
trade_count: z.number().int().nonnegative().optional().default(0),
|
||
/** K 线是否闭合 */
|
||
is_closed: z.boolean().optional().default(true),
|
||
});
|
||
|
||
export type KlineRaw = z.infer<typeof KlineRawSchema>;
|
||
|
||
/** 批量 K 线消息校验(WebSocket 可能一次推送多根) */
|
||
export const KlineBatchSchema = z.array(KlineRawSchema).min(1);
|
||
|
||
export type KlineBatch = z.infer<typeof KlineBatchSchema>;
|
||
|
||
// ============================================================
|
||
// 2. 监控交易对配置校验 — monitored_symbols
|
||
// ============================================================
|
||
|
||
/** 插入监控标的 */
|
||
export const MonitoredSymbolInsertSchema = z.object({
|
||
exchange: ExchangeSchema,
|
||
symbol: SymbolSchema,
|
||
interval: KlineIntervalSchema,
|
||
enabled: z.boolean().optional().default(true),
|
||
/** 优先级 0-32767(SMALLINT 范围) */
|
||
priority: z
|
||
.number()
|
||
.int()
|
||
.min(0)
|
||
.max(32767)
|
||
.optional()
|
||
.default(0),
|
||
label: z.string().max(200).nullable().optional().default(null),
|
||
notes: z.string().max(1000).nullable().optional().default(null),
|
||
});
|
||
|
||
export type MonitoredSymbolInsert = z.infer<
|
||
typeof MonitoredSymbolInsertSchema
|
||
>;
|
||
|
||
/** 更新监控标的 */
|
||
export const MonitoredSymbolUpdateSchema = z.object({
|
||
enabled: z.boolean().optional(),
|
||
priority: z.number().int().min(0).max(32767).optional(),
|
||
label: z.string().max(200).nullable().optional(),
|
||
notes: z.string().max(1000).nullable().optional(),
|
||
});
|
||
|
||
export type MonitoredSymbolUpdate = z.infer<
|
||
typeof MonitoredSymbolUpdateSchema
|
||
>;
|
||
|
||
// ============================================================
|
||
// 3. 交易所连接配置校验 — exchange_config
|
||
// ============================================================
|
||
|
||
/** 交易所连接配置输入 */
|
||
export const ExchangeConfigInsertSchema = z.object({
|
||
exchange: ExchangeSchema,
|
||
rest_url: z.string().url().nullable().optional().default(null),
|
||
ws_url: z.string().url().nullable().optional().default(null),
|
||
ws_ping_interval_ms: z
|
||
.number()
|
||
.int()
|
||
.min(5000)
|
||
.max(300000)
|
||
.optional()
|
||
.default(30000),
|
||
rate_limit_per_sec: z.number().positive().max(100).optional().default(20),
|
||
max_reconnect_attempts: z
|
||
.number()
|
||
.int()
|
||
.min(0)
|
||
.max(100)
|
||
.optional()
|
||
.default(10),
|
||
reconnect_delay_ms: z
|
||
.number()
|
||
.int()
|
||
.min(100)
|
||
.max(60000)
|
||
.optional()
|
||
.default(3000),
|
||
enabled: z.boolean().optional().default(true),
|
||
notes: z.string().max(500).nullable().optional().default(null),
|
||
});
|
||
|
||
export type ExchangeConfigInsert = z.infer<
|
||
typeof ExchangeConfigInsertSchema
|
||
>;
|
||
|
||
// ============================================================
|
||
// 4. 流标识校验 — StreamKey
|
||
// ============================================================
|
||
|
||
/** (exchange, symbol, interval) 三元组 */
|
||
export const StreamKeySchema = z.object({
|
||
exchange: ExchangeSchema,
|
||
symbol: SymbolSchema,
|
||
interval: KlineIntervalSchema,
|
||
});
|
||
|
||
export type StreamKey = z.infer<typeof StreamKeySchema>;
|
||
|
||
// ============================================================
|
||
// 5. 环境变量 / 配置校验
|
||
// ============================================================
|
||
|
||
/** .env 环境变量 schema */
|
||
export const EnvConfigSchema = z.object({
|
||
/** 逗号分隔的交易对列表 */
|
||
SYMBOLS: z
|
||
.string()
|
||
.optional()
|
||
.default("BTCUSDT,ETHUSDT"),
|
||
DB_HOST: z.string().optional().default("localhost"),
|
||
DB_PORT: z.coerce.number().int().positive().optional().default(5432),
|
||
DB_NAME: z.string().optional().default("trade"),
|
||
DB_USER: z.string().optional().default("trader"),
|
||
DB_PASSWORD: z.string().optional().default("changeme"),
|
||
REDIS_URL: z.string().url().optional().default("redis://localhost:6379"),
|
||
REDIS_PUBLISH_ENABLED: z
|
||
.enum(["true", "false"])
|
||
.optional()
|
||
.default("true"),
|
||
BATCH_SIZE: z.coerce.number().int().positive().optional().default(500),
|
||
FLUSH_INTERVAL_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.positive()
|
||
.optional()
|
||
.default(1000),
|
||
/** WebSocket 断线重连延迟基数(毫秒) */
|
||
WS_RECONNECT_DELAY_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.positive()
|
||
.optional()
|
||
.default(3000),
|
||
/** WebSocket 心跳间隔(毫秒) */
|
||
WS_PING_INTERVAL_MS: z.coerce
|
||
.number()
|
||
.int()
|
||
.positive()
|
||
.optional()
|
||
.default(30000),
|
||
/** WebSocket 最大重连次数 */
|
||
WS_MAX_RECONNECT_ATTEMPTS: z.coerce
|
||
.number()
|
||
.int()
|
||
.nonnegative()
|
||
.optional()
|
||
.default(10),
|
||
LOG_LEVEL: LogLevelSchema.optional().default("info"),
|
||
NODE_ENV: z
|
||
.enum(["development", "production", "test"])
|
||
.optional()
|
||
.default("development"),
|
||
});
|
||
|
||
export type EnvConfig = z.infer<typeof EnvConfigSchema>;
|