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:
@@ -5,10 +5,6 @@
|
||||
# cp .env.example .env
|
||||
# ============================================================
|
||||
|
||||
# --- 行情订阅 ---
|
||||
# 逗号分隔的交易对列表(大写)
|
||||
SYMBOLS=BTCUSDT,ETHUSDT
|
||||
|
||||
# --- TimescaleDB 连接 ---
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
// ============================================================
|
||||
// config.ts — 中心化配置模块(带 Zod 运行时校验)
|
||||
// ============================================================
|
||||
// 职责:
|
||||
// 1. 从 .env 文件加载环境变量(零依赖,手动解析)
|
||||
// 2. 使用 EnvConfigSchema 校验并类型收窄
|
||||
// 3. 导出按职责分组的强类型配置对象(pgsql / redis / batch / ws / logging / symbols)
|
||||
//
|
||||
// 使用方式:
|
||||
// import { pgsql, redis, batch, ws, logging, symbols } from "./config";
|
||||
// const pool = new pg.Pool(pgsql);
|
||||
// const redisClient = new Redis(redis.url);
|
||||
// ============================================================
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { EnvConfigSchema, type EnvConfig } from "./db/validators";
|
||||
|
||||
// ============================================================
|
||||
// 1. 加载 .env 文件(零依赖实现)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 手动解析 .env 文件为 key-value 对。
|
||||
* 规则:
|
||||
* - 忽略空行和 # 开头的注释行
|
||||
* - 首个 = 分割 key=value
|
||||
* - 去除首尾空白(不处理引号,值原样保留)
|
||||
* - 跳过不含 = 的行
|
||||
*
|
||||
* 不依赖 dotenv 包,保持依赖精简。
|
||||
*/
|
||||
function parseEnvFile(filePath: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
// 跳过空行和注释
|
||||
if (trimmed === "" || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx === -1) {
|
||||
continue;
|
||||
}
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const value = trimmed.slice(eqIdx + 1).trim();
|
||||
if (key !== "") {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// .env 文件不存在时不报错(生产环境变量由容器注入)
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将解析结果注入 process.env(已存在的变量不覆盖)。
|
||||
* 这使得后续 Zod 的 `z.coerce` 可以从 process.env 读取。
|
||||
*/
|
||||
function loadEnvFile(): void {
|
||||
// __dirname 在 ESM 中不可用,手动计算
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const envPath = resolve(__dirname, ".env");
|
||||
|
||||
const parsed = parseEnvFile(envPath);
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
// 不覆盖已由系统设置的环境变量(Docker / systemd 注入优先)
|
||||
if (process.env[key] === undefined) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模块加载时立即执行
|
||||
loadEnvFile();
|
||||
|
||||
// ============================================================
|
||||
// 2. Zod 校验 & 类型收窄
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 经 Zod 校验后的环境变量配置。
|
||||
* 所有字段均有默认值兜底,即使 .env 缺失也能正常运行。
|
||||
*/
|
||||
const rawConfig: EnvConfig = EnvConfigSchema.parse(process.env);
|
||||
|
||||
// ============================================================
|
||||
// 3. 按职责分组的导出配置对象
|
||||
// ============================================================
|
||||
|
||||
/** PostgreSQL / TimescaleDB 连接配置 */
|
||||
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 连接与发布配置 */
|
||||
export const redis = {
|
||||
/** Redis 连接 URL(ioredis 可直接使用) */
|
||||
url: rawConfig.REDIS_URL,
|
||||
/** 是否启用 Pub/Sub 发布行情数据(开发环境可关闭以节省资源) */
|
||||
publishEnabled: rawConfig.REDIS_PUBLISH_ENABLED === "true",
|
||||
/** 频道前缀,避免多环境 key 冲突 */
|
||||
channelPrefix: "trade",
|
||||
/** 重连策略:指数退避基数(毫秒) */
|
||||
retryDelayBaseMs: 1000,
|
||||
/** 最大重试次数 */
|
||||
maxRetries: 10,
|
||||
} as const;
|
||||
|
||||
/** K 线批量写入配置 */
|
||||
export const batch = {
|
||||
/** 缓冲区条数阈值(达到后自动刷新) */
|
||||
size: rawConfig.BATCH_SIZE,
|
||||
/** 最大缓冲时间(毫秒),超时后即使未达阈值也刷新 */
|
||||
flushIntervalMs: rawConfig.FLUSH_INTERVAL_MS,
|
||||
} as const;
|
||||
|
||||
/** WebSocket 连接配置(全局默认值,交易所级别可覆盖) */
|
||||
export const ws = {
|
||||
/** 断线重连延迟基数(毫秒),指数退避:基数 × 2^attempts */
|
||||
reconnectDelayMs: rawConfig.WS_RECONNECT_DELAY_MS,
|
||||
/** 心跳间隔(毫秒) */
|
||||
pingIntervalMs: rawConfig.WS_PING_INTERVAL_MS,
|
||||
/** 最大重连次数(超过后标记 error 状态) */
|
||||
maxReconnectAttempts: rawConfig.WS_MAX_RECONNECT_ATTEMPTS,
|
||||
} as const;
|
||||
|
||||
/** 日志配置 */
|
||||
export const logging = {
|
||||
/** 日志级别:trace / debug / info / warn / error / fatal */
|
||||
level: rawConfig.LOG_LEVEL,
|
||||
/** 运行环境(production 时 pino 输出 JSON 便于日志采集) */
|
||||
nodeEnv: rawConfig.NODE_ENV,
|
||||
/** 是否启用 pino-pretty(开发环境友好输出) */
|
||||
pretty: rawConfig.NODE_ENV === "development",
|
||||
} as const;
|
||||
|
||||
/** 默认订阅的交易对列表(逗号分隔 → string[]) */
|
||||
export const symbols: string[] = rawConfig.SYMBOLS.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// ============================================================
|
||||
// 4. 工具:运行时打印配置概要(不含敏感信息)
|
||||
// ============================================================
|
||||
|
||||
/** 打印脱敏后的配置概要,便于启动排查 */
|
||||
export function printConfigSummary(): void {
|
||||
const summary = {
|
||||
pgsql: {
|
||||
host: pgsql.host,
|
||||
port: pgsql.port,
|
||||
database: pgsql.database,
|
||||
user: pgsql.user,
|
||||
password: "***",
|
||||
},
|
||||
redis: {
|
||||
url: redis.url.replace(/\/\/.*@/, "//***@"), // 隐藏密码
|
||||
publishEnabled: redis.publishEnabled,
|
||||
},
|
||||
batch,
|
||||
ws,
|
||||
logging,
|
||||
symbols,
|
||||
};
|
||||
console.log("[config] 配置概要:", JSON.stringify(summary, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
// ============================================================
|
||||
// db/config-crud.ts — 配置表 CRUD 服务层
|
||||
// ============================================================
|
||||
// 职责:
|
||||
// 1. 封装 monitored_symbols / exchange_config / app_config 三张配置表的增删改查
|
||||
// 2. 所有方法通过 pg.Pool 执行参数化 SQL(防注入)
|
||||
// 3. 返回类型与 types.ts 严格对应,调用方无需手动断言
|
||||
// 4. 支持依赖注入:构造函数接收 Pool,便于单元测试 mock
|
||||
//
|
||||
// 使用方式:
|
||||
// import { pool } from "./db";
|
||||
// import { MonitoredSymbolsRepo, ExchangeConfigRepo, AppConfigRepo } from "./db/config-crud";
|
||||
//
|
||||
// const symbolsRepo = new MonitoredSymbolsRepo(pool);
|
||||
// const all = await symbolsRepo.listAll();
|
||||
// ============================================================
|
||||
|
||||
import type pg from "pg";
|
||||
import type {
|
||||
MonitoredSymbolRow,
|
||||
MonitoredSymbolInsert,
|
||||
MonitoredSymbolUpdate,
|
||||
ExchangeConfigRow,
|
||||
ExchangeConfigInsert,
|
||||
AppConfigRow,
|
||||
Exchange,
|
||||
KlineInterval,
|
||||
} from "./types";
|
||||
import {
|
||||
// monitored_symbols
|
||||
queryAllMonitoredSymbols,
|
||||
queryEnabledSymbols,
|
||||
querySymbolsByExchange,
|
||||
queryMonitoredSymbolById,
|
||||
queryMonitoredSymbolByKey,
|
||||
upsertMonitoredSymbol,
|
||||
updateMonitoredSymbol,
|
||||
disableMonitoredSymbol,
|
||||
deleteMonitoredSymbol,
|
||||
deleteMonitoredSymbolByKey,
|
||||
// exchange_config
|
||||
queryAllExchangeConfigs,
|
||||
queryEnabledExchanges,
|
||||
queryExchangeConfig,
|
||||
queryExchangeConfigById,
|
||||
upsertExchangeConfig,
|
||||
updateExchangeConfig,
|
||||
deleteExchangeConfig,
|
||||
deleteExchangeConfigByExchange,
|
||||
// app_config
|
||||
queryAllAppConfig,
|
||||
queryAppConfig,
|
||||
queryAppConfigById,
|
||||
upsertAppConfig,
|
||||
updateAppConfig,
|
||||
deleteAppConfig,
|
||||
deleteAppConfigById,
|
||||
} from "./queries";
|
||||
|
||||
// ============================================================
|
||||
// 工具类型:将 Promise 解包的一行或 null
|
||||
// ============================================================
|
||||
|
||||
/** pg query 返回的第一行,不存在则为 null */
|
||||
type FirstRow<T> = T | null;
|
||||
|
||||
// ============================================================
|
||||
// MonitoredSymbolsRepo — 监控交易对配置 CRUD
|
||||
// ============================================================
|
||||
|
||||
export class MonitoredSymbolsRepo {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// CREATE / UPSERT
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 新增或更新监控标的。
|
||||
* 唯一键冲突时更新 enabled/priority/label/notes 并刷新 updated_at。
|
||||
*
|
||||
* @returns 插入或更新后的完整行
|
||||
*/
|
||||
async upsert(
|
||||
insert: MonitoredSymbolInsert,
|
||||
): Promise<MonitoredSymbolRow> {
|
||||
const { rows } = await this.pool.query<MonitoredSymbolRow>(
|
||||
upsertMonitoredSymbol,
|
||||
[
|
||||
insert.exchange,
|
||||
insert.symbol,
|
||||
insert.interval,
|
||||
insert.enabled ?? true,
|
||||
insert.priority ?? 0,
|
||||
insert.label ?? null,
|
||||
insert.notes ?? null,
|
||||
],
|
||||
);
|
||||
return rows[0]!;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// READ — 单条
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/** 按主键 ID 查询 */
|
||||
async findById(id: number): Promise<FirstRow<MonitoredSymbolRow>> {
|
||||
const { rows } = await this.pool.query<MonitoredSymbolRow>(
|
||||
queryMonitoredSymbolById,
|
||||
[id],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按唯一业务键 (exchange, symbol, interval) 查询。
|
||||
* 这是最常用的精确查找方式。
|
||||
*/
|
||||
async findByKey(
|
||||
exchange: Exchange,
|
||||
symbol: string,
|
||||
interval: KlineInterval,
|
||||
): Promise<FirstRow<MonitoredSymbolRow>> {
|
||||
const { rows } = await this.pool.query<MonitoredSymbolRow>(
|
||||
queryMonitoredSymbolByKey,
|
||||
[exchange, symbol, interval],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// READ — 列表
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/** 查询所有监控标的(含已禁用),按优先级降序 */
|
||||
async listAll(): Promise<MonitoredSymbolRow[]> {
|
||||
const { rows } = await this.pool.query<MonitoredSymbolRow>(
|
||||
queryAllMonitoredSymbols,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 查询所有启用的监控标的(采集服务启动时调用) */
|
||||
async listEnabled(): Promise<MonitoredSymbolRow[]> {
|
||||
const { rows } = await this.pool.query<MonitoredSymbolRow>(
|
||||
queryEnabledSymbols,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 查询指定交易所下所有启用的监控标的 */
|
||||
async listByExchange(exchange: Exchange): Promise<MonitoredSymbolRow[]> {
|
||||
const { rows } = await this.pool.query<MonitoredSymbolRow>(
|
||||
querySymbolsByExchange,
|
||||
[exchange],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// UPDATE
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 按 ID 部分更新监控标的。
|
||||
* 仅更新传入的非 undefined 字段(COALESCE 语义)。
|
||||
*
|
||||
* @returns 更新后的完整行;ID 不存在则返回 null
|
||||
*/
|
||||
async update(
|
||||
id: number,
|
||||
patch: MonitoredSymbolUpdate,
|
||||
): Promise<FirstRow<MonitoredSymbolRow>> {
|
||||
const { rows } = await this.pool.query<MonitoredSymbolRow>(
|
||||
updateMonitoredSymbol,
|
||||
[
|
||||
id,
|
||||
patch.enabled ?? null,
|
||||
patch.priority ?? null,
|
||||
patch.label ?? null,
|
||||
patch.notes ?? null,
|
||||
],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用指定监控标的(软删除)。
|
||||
* 不会删除记录,仅将 enabled 设为 FALSE。
|
||||
*/
|
||||
async disable(
|
||||
exchange: Exchange,
|
||||
symbol: string,
|
||||
interval: KlineInterval,
|
||||
): Promise<Pick<MonitoredSymbolRow, "id" | "exchange" | "symbol" | "interval"> | null> {
|
||||
const { rows } = await this.pool.query<
|
||||
Pick<MonitoredSymbolRow, "id" | "exchange" | "symbol" | "interval">
|
||||
>(disableMonitoredSymbol, [exchange, symbol, interval]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// DELETE(硬删除)
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/** 按 ID 硬删除。返回被删除的 id,不存在则返回 null */
|
||||
async deleteById(id: number): Promise<number | null> {
|
||||
const { rows } = await this.pool.query<{ id: number }>(
|
||||
deleteMonitoredSymbol,
|
||||
[id],
|
||||
);
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
/** 按唯一键硬删除。返回被删除的 id,不存在则返回 null */
|
||||
async deleteByKey(
|
||||
exchange: Exchange,
|
||||
symbol: string,
|
||||
interval: KlineInterval,
|
||||
): Promise<number | null> {
|
||||
const { rows } = await this.pool.query<{ id: number }>(
|
||||
deleteMonitoredSymbolByKey,
|
||||
[exchange, symbol, interval],
|
||||
);
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ExchangeConfigRepo — 交易所连接配置 CRUD
|
||||
// ============================================================
|
||||
|
||||
export class ExchangeConfigRepo {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// CREATE / UPSERT
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 新增或更新交易所配置。
|
||||
* 唯一键冲突时更新所有连接参数并刷新 updated_at。
|
||||
*
|
||||
* @returns 插入或更新后的完整行
|
||||
*/
|
||||
async upsert(
|
||||
insert: ExchangeConfigInsert,
|
||||
): Promise<ExchangeConfigRow> {
|
||||
const { rows } = await this.pool.query<ExchangeConfigRow>(
|
||||
upsertExchangeConfig,
|
||||
[
|
||||
insert.exchange,
|
||||
insert.rest_url ?? null,
|
||||
insert.ws_url ?? null,
|
||||
insert.ws_ping_interval_ms ?? 30000,
|
||||
insert.rate_limit_per_sec ?? 20,
|
||||
insert.max_reconnect_attempts ?? 10,
|
||||
insert.reconnect_delay_ms ?? 3000,
|
||||
insert.enabled ?? true,
|
||||
insert.notes ?? null,
|
||||
],
|
||||
);
|
||||
return rows[0]!;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// READ — 单条
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/** 按主键 ID 查询 */
|
||||
async findById(id: number): Promise<FirstRow<ExchangeConfigRow>> {
|
||||
const { rows } = await this.pool.query<ExchangeConfigRow>(
|
||||
queryExchangeConfigById,
|
||||
[id],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** 按交易所标识查询(如 "binance") */
|
||||
async findByExchange(
|
||||
exchange: Exchange,
|
||||
): Promise<FirstRow<ExchangeConfigRow>> {
|
||||
const { rows } = await this.pool.query<ExchangeConfigRow>(
|
||||
queryExchangeConfig,
|
||||
[exchange],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// READ — 列表
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/** 查询所有交易所配置(含已禁用) */
|
||||
async listAll(): Promise<ExchangeConfigRow[]> {
|
||||
const { rows } = await this.pool.query<ExchangeConfigRow>(
|
||||
queryAllExchangeConfigs,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 查询所有启用的交易所配置 */
|
||||
async listEnabled(): Promise<ExchangeConfigRow[]> {
|
||||
const { rows } = await this.pool.query<ExchangeConfigRow>(
|
||||
queryEnabledExchanges,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// UPDATE
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 按 ID 部分更新交易所配置。
|
||||
* 仅更新传入的非 undefined 字段(COALESCE 语义)。
|
||||
*
|
||||
* ⚠️ 风险提示:修改限频参数(rate_limit_per_sec)可能触发交易所封禁 IP。
|
||||
* 务必确认目标交易所的官方限频规则后再调整。
|
||||
*
|
||||
* @returns 更新后的完整行;ID 不存在则返回 null
|
||||
*/
|
||||
async update(
|
||||
id: number,
|
||||
patch: Partial<Omit<ExchangeConfigRow, "id" | "created_at" | "updated_at">>,
|
||||
): Promise<FirstRow<ExchangeConfigRow>> {
|
||||
const { rows } = await this.pool.query<ExchangeConfigRow>(
|
||||
updateExchangeConfig,
|
||||
[
|
||||
id,
|
||||
patch.rest_url ?? null,
|
||||
patch.ws_url ?? null,
|
||||
patch.ws_ping_interval_ms ?? null,
|
||||
patch.rate_limit_per_sec ?? null,
|
||||
patch.max_reconnect_attempts ?? null,
|
||||
patch.reconnect_delay_ms ?? null,
|
||||
patch.enabled ?? null,
|
||||
patch.notes ?? null,
|
||||
],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// DELETE(硬删除)
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/** 按 ID 硬删除。返回被删除的 id,不存在则返回 null */
|
||||
async deleteById(id: number): Promise<number | null> {
|
||||
const { rows } = await this.pool.query<{ id: number }>(
|
||||
deleteExchangeConfig,
|
||||
[id],
|
||||
);
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
/** 按交易所标识硬删除。返回被删除的 id,不存在则返回 null */
|
||||
async deleteByExchange(exchange: Exchange): Promise<number | null> {
|
||||
const { rows } = await this.pool.query<{ id: number }>(
|
||||
deleteExchangeConfigByExchange,
|
||||
[exchange],
|
||||
);
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AppConfigRepo — 全局应用配置(KV)CRUD
|
||||
// ============================================================
|
||||
|
||||
export class AppConfigRepo {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// CREATE / UPSERT
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 设置一个配置项(新增或更新)。
|
||||
*
|
||||
* @param key — 配置键
|
||||
* @param value — 配置值(字符串,消费方自行解析类型)
|
||||
* @param description — 可选说明
|
||||
* @returns 插入或更新后的完整行
|
||||
*/
|
||||
async set(
|
||||
key: string,
|
||||
value: string,
|
||||
description?: string | null,
|
||||
): Promise<AppConfigRow> {
|
||||
const { rows } = await this.pool.query<AppConfigRow>(upsertAppConfig, [
|
||||
key,
|
||||
value,
|
||||
description ?? null,
|
||||
]);
|
||||
return rows[0]!;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// READ — 单条
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/** 按主键 ID 查询 */
|
||||
async findById(id: number): Promise<FirstRow<AppConfigRow>> {
|
||||
const { rows } = await this.pool.query<AppConfigRow>(
|
||||
queryAppConfigById,
|
||||
[id],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 key 查询配置项。
|
||||
*
|
||||
* @returns 配置行;不存在则返回 null
|
||||
*/
|
||||
async get(key: string): Promise<FirstRow<AppConfigRow>> {
|
||||
const { rows } = await this.pool.query<AppConfigRow>(queryAppConfig, [key]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 key 获取配置值(字符串)。
|
||||
* 便捷方法——等价于 (await get(key))?.value ?? defaultValue。
|
||||
*
|
||||
* @param key — 配置键
|
||||
* @param defaultValue — 默认值(key 不存在时返回)
|
||||
*/
|
||||
async getValue(key: string, defaultValue = ""): Promise<string> {
|
||||
const row = await this.get(key);
|
||||
return row?.value ?? defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 key 获取配置值并解析为整数。
|
||||
* 解析失败时返回 defaultValue。
|
||||
*/
|
||||
async getIntValue(key: string, defaultValue = 0): Promise<number> {
|
||||
const raw = await this.getValue(key, String(defaultValue));
|
||||
const parsed = parseInt(raw, 10);
|
||||
return Number.isNaN(parsed) ? defaultValue : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 key 获取配置值并解析为布尔。
|
||||
* 规则:'true' / '1' → true,其余 → false。
|
||||
*/
|
||||
async getBoolValue(key: string, defaultValue = false): Promise<boolean> {
|
||||
const raw = await this.getValue(key, String(defaultValue));
|
||||
return raw === "true" || raw === "1";
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// READ — 列表
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/** 查询所有应用配置 */
|
||||
async listAll(): Promise<AppConfigRow[]> {
|
||||
const { rows } = await this.pool.query<AppConfigRow>(queryAllAppConfig);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取多个 key 的值。
|
||||
* 一次性查询全表后过滤,避免 N+1 问题。
|
||||
*
|
||||
* @param keys — 需要获取的 key 列表
|
||||
* @returns Map<key, value>
|
||||
*/
|
||||
async getBatch(keys: string[]): Promise<Map<string, string>> {
|
||||
const all = await this.listAll();
|
||||
const map = new Map<string, string>();
|
||||
const keySet = new Set(keys);
|
||||
for (const row of all) {
|
||||
if (keySet.has(row.key)) {
|
||||
map.set(row.key, row.value);
|
||||
}
|
||||
}
|
||||
// 保证未找到的 key 也有默认值 ""
|
||||
for (const k of keys) {
|
||||
if (!map.has(k)) {
|
||||
map.set(k, "");
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// UPDATE
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 按 ID 部分更新应用配置。
|
||||
*
|
||||
* @returns 更新后的完整行;ID 不存在则返回 null
|
||||
*/
|
||||
async update(
|
||||
id: number,
|
||||
value?: string,
|
||||
description?: string | null,
|
||||
): Promise<FirstRow<AppConfigRow>> {
|
||||
const { rows } = await this.pool.query<AppConfigRow>(updateAppConfig, [
|
||||
id,
|
||||
value ?? null,
|
||||
description ?? null,
|
||||
]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// DELETE
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/** 按 key 删除配置。返回被删除的 id,不存在则返回 null */
|
||||
async deleteByKey(key: string): Promise<number | null> {
|
||||
const { rows } = await this.pool.query<{ id: number }>(deleteAppConfig, [
|
||||
key,
|
||||
]);
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
/** 按 ID 删除配置。返回被删除的 id,不存在则返回 null */
|
||||
async deleteById(id: number): Promise<number | null> {
|
||||
const { rows } = await this.pool.query<{ id: number }>(
|
||||
deleteAppConfigById,
|
||||
[id],
|
||||
);
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// ============================================================
|
||||
// db/index.ts — 统一导出
|
||||
// ============================================================
|
||||
// 使用方式:
|
||||
// import { KlineRow, KlineRawSchema, bulkUpsertKlines } from "./db";
|
||||
// import { MonitoredSymbolsRepo } from "./db";
|
||||
// ============================================================
|
||||
|
||||
// 类型定义
|
||||
export type {
|
||||
Exchange,
|
||||
KlineInterval,
|
||||
LogLevel,
|
||||
KlineRow,
|
||||
KlineInsert,
|
||||
AggregatedKlineRow,
|
||||
MonitoredSymbolRow,
|
||||
MonitoredSymbolInsert,
|
||||
MonitoredSymbolUpdate,
|
||||
ExchangeConfigRow,
|
||||
ExchangeConfigInsert,
|
||||
AppConfigRow,
|
||||
AppConfigKey,
|
||||
StreamKey,
|
||||
StreamSubscription,
|
||||
} from "./types";
|
||||
|
||||
// Zod 运行时校验
|
||||
export {
|
||||
ExchangeSchema,
|
||||
KlineIntervalSchema,
|
||||
LogLevelSchema,
|
||||
SymbolSchema,
|
||||
NumericStringSchema,
|
||||
KlineRawSchema,
|
||||
KlineBatchSchema,
|
||||
MonitoredSymbolInsertSchema,
|
||||
MonitoredSymbolUpdateSchema,
|
||||
ExchangeConfigInsertSchema,
|
||||
StreamKeySchema,
|
||||
EnvConfigSchema,
|
||||
} from "./validators";
|
||||
|
||||
export type {
|
||||
KlineRaw,
|
||||
KlineBatch,
|
||||
MonitoredSymbolInsert as MonitoredSymbolInsertValidated,
|
||||
MonitoredSymbolUpdate as MonitoredSymbolUpdateValidated,
|
||||
ExchangeConfigInsert as ExchangeConfigInsertValidated,
|
||||
StreamKey as StreamKeyValidated,
|
||||
EnvConfig,
|
||||
} from "./validators";
|
||||
|
||||
// 参数化 SQL 查询
|
||||
export {
|
||||
bulkUpsertKlines,
|
||||
packBulkKlines,
|
||||
queryKlinesRange,
|
||||
queryKlinesLatest,
|
||||
queryAggregatedKlines,
|
||||
// monitored_symbols
|
||||
queryAllMonitoredSymbols,
|
||||
queryEnabledSymbols,
|
||||
querySymbolsByExchange,
|
||||
queryMonitoredSymbolById,
|
||||
queryMonitoredSymbolByKey,
|
||||
upsertMonitoredSymbol,
|
||||
updateMonitoredSymbol,
|
||||
disableMonitoredSymbol,
|
||||
deleteMonitoredSymbol,
|
||||
deleteMonitoredSymbolByKey,
|
||||
// exchange_config
|
||||
queryAllExchangeConfigs,
|
||||
queryEnabledExchanges,
|
||||
queryExchangeConfig,
|
||||
queryExchangeConfigById,
|
||||
upsertExchangeConfig,
|
||||
updateExchangeConfig,
|
||||
deleteExchangeConfig,
|
||||
deleteExchangeConfigByExchange,
|
||||
// app_config
|
||||
queryAllAppConfig,
|
||||
queryAppConfig,
|
||||
queryAppConfigById,
|
||||
upsertAppConfig,
|
||||
updateAppConfig,
|
||||
deleteAppConfig,
|
||||
deleteAppConfigById,
|
||||
// 复合查询
|
||||
queryStreamSubscriptions,
|
||||
} from "./queries";
|
||||
|
||||
export type { BulkKlineParams } from "./queries";
|
||||
|
||||
// ============================================================
|
||||
// Config CRUD 服务层(推荐使用)
|
||||
// ============================================================
|
||||
|
||||
export {
|
||||
MonitoredSymbolsRepo,
|
||||
ExchangeConfigRepo,
|
||||
AppConfigRepo,
|
||||
} from "./config-crud";
|
||||
|
||||
// ============================================================
|
||||
// PostgreSQL 连接池 & 工具
|
||||
// ============================================================
|
||||
|
||||
export {
|
||||
pool,
|
||||
healthCheck,
|
||||
timescaleVersion,
|
||||
withTransaction,
|
||||
closePool,
|
||||
registerShutdownHandlers,
|
||||
initSchemaFromFile,
|
||||
initSchema,
|
||||
isSchemaInitialized,
|
||||
} from "./pg";
|
||||
|
||||
export { default as defaultPool } from "./pg";
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
// ============================================================
|
||||
// db/pg.ts — PostgreSQL / TimescaleDB 连接池管理
|
||||
// ============================================================
|
||||
// 职责:
|
||||
// 1. 基于 config.ts 的 pgsql 配置创建 pg.Pool 单例
|
||||
// 2. 连接生命周期事件监听(connect / acquire / remove / error)
|
||||
// 3. 提供健康检查(healthCheck)
|
||||
// 4. 提供事务辅助函数(withTransaction)
|
||||
// 5. 进程退出时优雅关闭(SIGTERM / SIGINT)
|
||||
//
|
||||
// 使用方式:
|
||||
// import { pool } from "./db"; // 通过 index.ts 统一导出
|
||||
// import { healthCheck } from "./db";
|
||||
// const { rows } = await pool.query("SELECT NOW()");
|
||||
// ============================================================
|
||||
|
||||
import pg from "pg";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pgsql } from "../config";
|
||||
|
||||
// ============================================================
|
||||
// 1. 连接池创建(单例)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* pg.Pool 单例。
|
||||
* 配置来源于 config.ts → pgsql,已包含连接数上限、超时等参数。
|
||||
*
|
||||
* pg.Pool 内部使用懒连接——首次 query 时才建立连接,
|
||||
* 因此模块加载时不会立即连接数据库。
|
||||
*/
|
||||
export const pool = new pg.Pool({
|
||||
host: pgsql.host,
|
||||
port: pgsql.port,
|
||||
database: pgsql.database,
|
||||
user: pgsql.user,
|
||||
password: pgsql.password,
|
||||
max: pgsql.max,
|
||||
idleTimeoutMillis: pgsql.idleTimeoutMillis,
|
||||
connectionTimeoutMillis: pgsql.connectionTimeoutMillis,
|
||||
// TimescaleDB 特有的超时设置:分析查询可能较慢
|
||||
statement_timeout: 30000, // 单条 SQL 最大执行 30s
|
||||
// application_name 便于在 pg_stat_activity 中识别
|
||||
application_name: "trade-data",
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 2. 连接池事件监听(可观测性)
|
||||
// ============================================================
|
||||
|
||||
/** 新客户端连接建立 */
|
||||
pool.on("connect", (client) => {
|
||||
// 为每个连接设置 TimescaleDB 优化参数
|
||||
// 跳过 WAL 日志可加速批量写入(仅在可接受丢失最近几秒数据的场景)
|
||||
// client.query("SET timescaledb.enable_skip_scan = ON");
|
||||
console.log(`[pg] 新连接建立 (total: ${pool.totalCount}, idle: ${pool.idleCount})`);
|
||||
});
|
||||
|
||||
/** 从池中获取连接 */
|
||||
pool.on("acquire", () => {
|
||||
// 连接池耗尽时会频繁触发,可在此记录高负载信号
|
||||
});
|
||||
|
||||
/** 连接归还池 */
|
||||
pool.on("remove", () => {
|
||||
console.log(`[pg] 连接关闭 (total: ${pool.totalCount}, idle: ${pool.idleCount})`);
|
||||
});
|
||||
|
||||
/**
|
||||
* 空闲客户端出错(如网络中断、PG 重启)。
|
||||
* pg.Pool 会自动移除问题连接并创建新连接,此处仅记录日志。
|
||||
*/
|
||||
pool.on("error", (err: Error) => {
|
||||
console.error(`[pg] 连接池错误: ${err.message}`);
|
||||
// 不退出进程——连接池会自动恢复
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 3. 健康检查
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 数据库连通性检查。
|
||||
* 执行轻量查询 `SELECT 1`,超时 5 秒。
|
||||
*
|
||||
* @returns true 表示数据库可达
|
||||
*/
|
||||
export async function healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("SELECT 1");
|
||||
return true;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度健康检查:验证 TimescaleDB 扩展是否已安装。
|
||||
* 仅在首次连接或定期巡检时调用。
|
||||
*
|
||||
* @returns TimescaleDB 版本字符串,未安装则返回 null
|
||||
*/
|
||||
export async function timescaleVersion(): Promise<string | null> {
|
||||
try {
|
||||
const { rows } = await pool.query<{ extversion: string }>(
|
||||
"SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'",
|
||||
);
|
||||
return rows[0]?.extversion ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 4. 事务辅助
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 在单连接上执行事务。
|
||||
* 自动 BEGIN / COMMIT / ROLLBACK,连接用完即释放。
|
||||
*
|
||||
* @param fn - 事务体,接收 pg.PoolClient,返回 Promise<T>
|
||||
* @returns fn 的返回值
|
||||
* @throws 事务内任何异常都会触发 ROLLBACK 并向上抛出
|
||||
*
|
||||
* @example
|
||||
* const result = await withTransaction(async (client) => {
|
||||
* await client.query("INSERT INTO ...");
|
||||
* await client.query("UPDATE ...");
|
||||
* return { success: true };
|
||||
* });
|
||||
*/
|
||||
export async function withTransaction<T>(
|
||||
fn: (client: pg.PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await fn(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 5. 优雅关闭
|
||||
// ============================================================
|
||||
|
||||
/** 是否正在关闭(防止重复调用) */
|
||||
let shuttingDown = false;
|
||||
|
||||
/**
|
||||
* 关闭连接池。
|
||||
* 先等待所有进行中的查询完成(drain),再断开所有连接。
|
||||
*
|
||||
* 应在进程退出前调用:SIGTERM / SIGINT 处理器中。
|
||||
*/
|
||||
export async function closePool(): Promise<void> {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
|
||||
console.log("[pg] 正在关闭连接池...");
|
||||
// pool.end() 等待所有活跃查询完成后关闭
|
||||
await pool.end();
|
||||
console.log("[pg] 连接池已关闭");
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册进程信号处理器——优雅关闭。
|
||||
* 在应用入口调用一次即可。
|
||||
*/
|
||||
export function registerShutdownHandlers(): void {
|
||||
const shutdown = async (signal: string) => {
|
||||
console.log(`[pg] 收到 ${signal} 信号,开始优雅关闭...`);
|
||||
await closePool();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.once("SIGINT", () => shutdown("SIGINT"));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 6. Schema 初始化(基于 data/schema/*.sql)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 将 SQL 文本按语句拆分为独立命令。
|
||||
* 规则:
|
||||
* - 按 `;` 分割
|
||||
* - 跳过空行和纯注释行
|
||||
* - 单个语句去除首尾空白后若为空则跳过
|
||||
*
|
||||
* 注意:此方法假设 SQL 中 `;` 仅作为语句分隔符,
|
||||
* 不含存储过程/函数体内的 `;`(schema/*.sql 满足此条件)。
|
||||
*/
|
||||
function splitSQLStatements(sql: string): string[] {
|
||||
const statements: string[] = [];
|
||||
for (const raw of sql.split(";")) {
|
||||
const trimmed = raw.trim();
|
||||
// 跳过空语句
|
||||
if (trimmed === "") {
|
||||
continue;
|
||||
}
|
||||
// 跳过仅包含注释的行(已在上层过滤,此处兜底)
|
||||
statements.push(trimmed);
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从单个 .sql 文件初始化 Schema。
|
||||
* 读取文件 → 拆分语句 → 逐条执行(同一连接,保证顺序)。
|
||||
*
|
||||
* 所有 SQL 均使用 IF NOT EXISTS / ON CONFLICT DO NOTHING,
|
||||
* 因此重复执行安全幂等。
|
||||
*
|
||||
* @param filePath - SQL 文件的绝对路径
|
||||
* @returns 成功执行的语句数
|
||||
*/
|
||||
export async function initSchemaFromFile(filePath: string): Promise<number> {
|
||||
const sql = readFileSync(filePath, "utf-8");
|
||||
|
||||
// 预处理:移除纯注释行(以 -- 开头),减少无效语句
|
||||
const lines = sql
|
||||
.split("\n")
|
||||
.filter((line: string) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed !== "" && !trimmed.startsWith("--");
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const statements = splitSQLStatements(lines);
|
||||
if (statements.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let executed = 0;
|
||||
// 使用单连接执行所有语句,保证 DDL 顺序(如先建表再建索引)
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
for (const stmt of statements) {
|
||||
await client.query(stmt);
|
||||
executed++;
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return executed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 data/schema/ 目录初始化所有表结构。
|
||||
*
|
||||
* 执行顺序(按文件名排序):
|
||||
* 1. klines.sql — K 线主表(hypertable)、索引、压缩、连续聚合
|
||||
* 2. config.sql — 配置表(monitored_symbols / exchange_config / app_config)+ 预置数据
|
||||
*
|
||||
* Docker Compose 首次启动时,001_init.sql 已被自动执行。
|
||||
* 此函数作为补充,确保以下场景:
|
||||
* - 裸 PostgreSQL 安装(非 Docker 部署)
|
||||
* - 版本升级时需要新增表/索引
|
||||
* - 开发环境快速重置 Schema
|
||||
*
|
||||
* @param schemaDir - schema 目录路径,默认 ../schema(相对于本文件)
|
||||
* @returns 各文件执行结果摘要
|
||||
*/
|
||||
export async function initSchema(schemaDir?: string): Promise<{
|
||||
files: string[];
|
||||
totalStatements: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
// 计算 schema 目录绝对路径(ESM 中 __dirname 不可用)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const dir = schemaDir ?? resolve(__dirname, "..", "schema");
|
||||
|
||||
const result = { files: [] as string[], totalStatements: 0, errors: [] as string[] };
|
||||
|
||||
// 读取目录,按文件名排序保证执行顺序(klines.sql 先于 config.sql)
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir)
|
||||
.filter((f: string) => f.endsWith(".sql"))
|
||||
.sort(); // 字母序
|
||||
} catch {
|
||||
result.errors.push(`无法读取 schema 目录: ${dir}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 手动排序:klines.sql 必须在 config.sql 之前(外键/引用依赖)
|
||||
const klinesFirst = ["klines.sql", "config.sql"];
|
||||
entries.sort((a, b) => {
|
||||
const ia = klinesFirst.indexOf(a);
|
||||
const ib = klinesFirst.indexOf(b);
|
||||
if (ia !== -1 && ib !== -1) return ia - ib;
|
||||
if (ia !== -1) return -1;
|
||||
if (ib !== -1) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const filePath = resolve(dir, entry);
|
||||
try {
|
||||
const count = await initSchemaFromFile(filePath);
|
||||
result.files.push(`${entry} (${count} 条)`);
|
||||
result.totalStatements += count;
|
||||
} catch (err) {
|
||||
const msg = `${entry}: ${(err as Error).message}`;
|
||||
result.errors.push(msg);
|
||||
console.error(`[pg] Schema 初始化失败 — ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.errors.length === 0) {
|
||||
console.log(
|
||||
`[pg] Schema 初始化完成 — ${result.files.length} 个文件, ${result.totalStatements} 条语句`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速判断核心表是否已存在(轻量级检查,不做全量 Schema 验证)。
|
||||
* 仅在执行 initSchema() 前做一次探测,已存在则跳过。
|
||||
*
|
||||
* @returns true 表示 klines 和 monitored_symbols 表均已存在
|
||||
*/
|
||||
export async function isSchemaInitialized(): Promise<boolean> {
|
||||
try {
|
||||
const { rows } = await pool.query<{ count: string }>(`
|
||||
SELECT COUNT(*)::TEXT AS count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('klines', 'monitored_symbols', 'exchange_config', 'app_config')
|
||||
`);
|
||||
// 4 张核心表全部存在
|
||||
return parseInt(rows[0]?.count ?? "0", 10) >= 4;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 7. 默认导出(便于 import pool from "./db/pg")
|
||||
// ============================================================
|
||||
|
||||
export default pool;
|
||||
@@ -0,0 +1,561 @@
|
||||
// ============================================================
|
||||
// schema/queries.ts — 类型安全的参数化 SQL 查询
|
||||
// ============================================================
|
||||
// 每一条 SQL 都使用 $1, $2... 参数化,防止 SQL 注入。
|
||||
// 返回类型与 types.ts 中的接口严格对应。
|
||||
//
|
||||
// 使用方式:
|
||||
// import { pool } from "../db";
|
||||
// import { queryEnabledSymbols } from "./schema/queries";
|
||||
// const result = await pool.query(queryEnabledSymbols, ["binance"]);
|
||||
// // result.rows 自动推断为 MonitoredSymbolRow[]
|
||||
// ============================================================
|
||||
|
||||
import type {
|
||||
KlineInsert,
|
||||
KlineRow,
|
||||
AggregatedKlineRow,
|
||||
MonitoredSymbolRow,
|
||||
MonitoredSymbolInsert,
|
||||
ExchangeConfigRow,
|
||||
ExchangeConfigInsert,
|
||||
AppConfigRow,
|
||||
Exchange,
|
||||
KlineInterval,
|
||||
StreamKey,
|
||||
} from "./types";
|
||||
|
||||
// ============================================================
|
||||
// K 线查询 — klines
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 批量插入 K 线(UPSERT — 冲突时更新 OHLCV)
|
||||
*
|
||||
* 使用 UNNEST 批量写入,单次可插入数千条,性能远优于逐条 INSERT。
|
||||
* 冲突策略:ON CONFLICT 时更新价格/成交量/闭合状态。
|
||||
*/
|
||||
export const bulkUpsertKlines = `
|
||||
INSERT INTO klines (
|
||||
time, exchange, symbol, interval,
|
||||
open, high, low, close, volume,
|
||||
quote_volume, taker_buy_base_vol, taker_buy_quote_vol,
|
||||
trade_count, is_closed
|
||||
)
|
||||
SELECT * FROM UNNEST(
|
||||
$1::TIMESTAMPTZ[], -- time[]
|
||||
$2::TEXT[], -- exchange[]
|
||||
$3::TEXT[], -- symbol[]
|
||||
$4::TEXT[], -- interval[]
|
||||
$5::NUMERIC(20,8)[], -- open[]
|
||||
$6::NUMERIC(20,8)[], -- high[]
|
||||
$7::NUMERIC(20,8)[], -- low[]
|
||||
$8::NUMERIC(20,8)[], -- close[]
|
||||
$9::NUMERIC(20,8)[], -- volume[]
|
||||
$10::NUMERIC(20,8)[],-- quote_volume[]
|
||||
$11::NUMERIC(20,8)[],-- taker_buy_base_vol[]
|
||||
$12::NUMERIC(20,8)[],-- taker_buy_quote_vol[]
|
||||
$13::INTEGER[], -- trade_count[]
|
||||
$14::BOOLEAN[] -- is_closed[]
|
||||
)
|
||||
ON CONFLICT (time, exchange, symbol, interval) DO UPDATE SET
|
||||
open = EXCLUDED.open,
|
||||
high = EXCLUDED.high,
|
||||
low = EXCLUDED.low,
|
||||
close = EXCLUDED.close,
|
||||
volume = EXCLUDED.volume,
|
||||
quote_volume = EXCLUDED.quote_volume,
|
||||
taker_buy_base_vol = EXCLUDED.taker_buy_base_vol,
|
||||
taker_buy_quote_vol = EXCLUDED.taker_buy_quote_vol,
|
||||
trade_count = EXCLUDED.trade_count,
|
||||
is_closed = EXCLUDED.is_closed,
|
||||
updated_at = NOW()
|
||||
`;
|
||||
|
||||
/** 批量插入的参数类型:每个字段是一个数组 */
|
||||
export interface BulkKlineParams {
|
||||
time: Date[];
|
||||
exchange: Exchange[];
|
||||
symbol: string[];
|
||||
interval: KlineInterval[];
|
||||
open: string[];
|
||||
high: string[];
|
||||
low: string[];
|
||||
close: string[];
|
||||
volume: string[];
|
||||
quote_volume: string[];
|
||||
taker_buy_base_vol: string[];
|
||||
taker_buy_quote_vol: string[];
|
||||
trade_count: number[];
|
||||
is_closed: boolean[];
|
||||
}
|
||||
|
||||
/** 将 KlineInsert[] 拆解为 BulkKlineParams */
|
||||
export function packBulkKlines(rows: KlineInsert[]): BulkKlineParams {
|
||||
const len = rows.length;
|
||||
const params: BulkKlineParams = {
|
||||
time: new Array(len),
|
||||
exchange: new Array(len),
|
||||
symbol: new Array(len),
|
||||
interval: new Array(len),
|
||||
open: new Array(len),
|
||||
high: new Array(len),
|
||||
low: new Array(len),
|
||||
close: new Array(len),
|
||||
volume: new Array(len),
|
||||
quote_volume: new Array(len),
|
||||
taker_buy_base_vol: new Array(len),
|
||||
taker_buy_quote_vol: new Array(len),
|
||||
trade_count: new Array(len),
|
||||
is_closed: new Array(len),
|
||||
};
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const r = rows[i]!;
|
||||
params.time[i] = r.time;
|
||||
params.exchange[i] = r.exchange;
|
||||
params.symbol[i] = r.symbol;
|
||||
params.interval[i] = r.interval;
|
||||
params.open[i] = r.open;
|
||||
params.high[i] = r.high;
|
||||
params.low[i] = r.low;
|
||||
params.close[i] = r.close;
|
||||
params.volume[i] = r.volume;
|
||||
params.quote_volume[i] = r.quote_volume ?? "0";
|
||||
params.taker_buy_base_vol[i] = r.taker_buy_base_vol ?? "0";
|
||||
params.taker_buy_quote_vol[i] = r.taker_buy_quote_vol ?? "0";
|
||||
params.trade_count[i] = r.trade_count ?? 0;
|
||||
params.is_closed[i] = r.is_closed ?? true;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询原始 K 线(时间范围)
|
||||
* 返回类型:KlineRow[]
|
||||
*/
|
||||
export const queryKlinesRange = `
|
||||
SELECT time, exchange, symbol, interval,
|
||||
open, high, low, close, volume,
|
||||
quote_volume, taker_buy_base_vol, taker_buy_quote_vol,
|
||||
trade_count, is_closed, created_at, updated_at
|
||||
FROM klines
|
||||
WHERE exchange = $1
|
||||
AND symbol = $2
|
||||
AND interval = $3
|
||||
AND time >= $4
|
||||
AND time < $5
|
||||
ORDER BY time ASC
|
||||
`;
|
||||
|
||||
/**
|
||||
* 查询最新 N 根 K 线
|
||||
* 返回类型:KlineRow[]
|
||||
*/
|
||||
export const queryKlinesLatest = `
|
||||
SELECT time, exchange, symbol, interval,
|
||||
open, high, low, close, volume,
|
||||
quote_volume, taker_buy_base_vol, taker_buy_quote_vol,
|
||||
trade_count, is_closed, created_at, updated_at
|
||||
FROM klines
|
||||
WHERE exchange = $1
|
||||
AND symbol = $2
|
||||
AND interval = $3
|
||||
ORDER BY time DESC
|
||||
LIMIT $4
|
||||
`;
|
||||
|
||||
// ============================================================
|
||||
// 聚合 K 线查询 — klines_5m / 15m / 1h / 1d
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 查询聚合 K 线(动态视图名)
|
||||
*
|
||||
* @param viewName — "klines_5m" | "klines_15m" | "klines_1h" | "klines_1d"
|
||||
*
|
||||
* 注意:视图名已通过枚举约束,不存在注入风险(不使用用户输入拼接)
|
||||
*/
|
||||
export function queryAggregatedKlines(
|
||||
viewName: "klines_5m" | "klines_15m" | "klines_1h" | "klines_1d",
|
||||
) {
|
||||
// 视图名来自代码常量,安全拼接
|
||||
return `
|
||||
SELECT time, exchange, symbol, interval,
|
||||
open, high, low, close, volume,
|
||||
quote_volume, taker_buy_base_vol, taker_buy_quote_vol, trade_count
|
||||
FROM ${viewName}
|
||||
WHERE exchange = $1
|
||||
AND symbol = $2
|
||||
AND time >= $3
|
||||
AND time < $4
|
||||
ORDER BY time ASC
|
||||
`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 监控交易对查询 — monitored_symbols
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 查询所有启用的监控标的(采集服务启动时调用)
|
||||
* 返回类型:MonitoredSymbolRow[]
|
||||
*/
|
||||
export const queryEnabledSymbols = `
|
||||
SELECT id, exchange, symbol, interval,
|
||||
enabled, priority, label, notes,
|
||||
created_at, updated_at
|
||||
FROM monitored_symbols
|
||||
WHERE enabled = TRUE
|
||||
ORDER BY exchange, priority DESC, symbol, interval
|
||||
`;
|
||||
|
||||
/**
|
||||
* 查询指定交易所的监控标的
|
||||
* 返回类型:MonitoredSymbolRow[]
|
||||
*/
|
||||
export const querySymbolsByExchange = `
|
||||
SELECT id, exchange, symbol, interval,
|
||||
enabled, priority, label, notes,
|
||||
created_at, updated_at
|
||||
FROM monitored_symbols
|
||||
WHERE exchange = $1
|
||||
AND enabled = TRUE
|
||||
ORDER BY priority DESC, symbol, interval
|
||||
`;
|
||||
|
||||
/**
|
||||
* 插入监控标的(UPSERT)
|
||||
*/
|
||||
export const upsertMonitoredSymbol = `
|
||||
INSERT INTO monitored_symbols (exchange, symbol, interval, enabled, priority, label, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (exchange, symbol, interval) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
priority = EXCLUDED.priority,
|
||||
label = EXCLUDED.label,
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = NOW()
|
||||
RETURNING id, exchange, symbol, interval,
|
||||
enabled, priority, label, notes,
|
||||
created_at, updated_at
|
||||
`;
|
||||
|
||||
/**
|
||||
* 禁用监控标的(软删除)
|
||||
*/
|
||||
export const disableMonitoredSymbol = `
|
||||
UPDATE monitored_symbols
|
||||
SET enabled = FALSE, updated_at = NOW()
|
||||
WHERE exchange = $1 AND symbol = $2 AND interval = $3
|
||||
RETURNING id, exchange, symbol, interval
|
||||
`;
|
||||
|
||||
/**
|
||||
* 按 ID 查询单个监控标的
|
||||
* 返回类型:MonitoredSymbolRow | null
|
||||
*/
|
||||
export const queryMonitoredSymbolById = `
|
||||
SELECT id, exchange, symbol, interval,
|
||||
enabled, priority, label, notes,
|
||||
created_at, updated_at
|
||||
FROM monitored_symbols
|
||||
WHERE id = $1
|
||||
`;
|
||||
|
||||
/**
|
||||
* 查询所有监控标的(含已禁用)
|
||||
* 返回类型:MonitoredSymbolRow[]
|
||||
*/
|
||||
export const queryAllMonitoredSymbols = `
|
||||
SELECT id, exchange, symbol, interval,
|
||||
enabled, priority, label, notes,
|
||||
created_at, updated_at
|
||||
FROM monitored_symbols
|
||||
ORDER BY exchange, priority DESC, symbol, interval
|
||||
`;
|
||||
|
||||
/**
|
||||
* 按唯一键 (exchange, symbol, interval) 查询监控标的
|
||||
* 返回类型:MonitoredSymbolRow | null
|
||||
*/
|
||||
export const queryMonitoredSymbolByKey = `
|
||||
SELECT id, exchange, symbol, interval,
|
||||
enabled, priority, label, notes,
|
||||
created_at, updated_at
|
||||
FROM monitored_symbols
|
||||
WHERE exchange = $1 AND symbol = $2 AND interval = $3
|
||||
`;
|
||||
|
||||
/**
|
||||
* 按 ID 删除监控标的(硬删除)
|
||||
* 返回被删除记录的 id
|
||||
*/
|
||||
export const deleteMonitoredSymbol = `
|
||||
DELETE FROM monitored_symbols
|
||||
WHERE id = $1
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
/**
|
||||
* 按唯一键删除监控标的(硬删除)
|
||||
* 返回被删除记录的 id
|
||||
*/
|
||||
export const deleteMonitoredSymbolByKey = `
|
||||
DELETE FROM monitored_symbols
|
||||
WHERE exchange = $1 AND symbol = $2 AND interval = $3
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
/**
|
||||
* 更新监控标的(部分字段)
|
||||
* 仅更新传入的非 NULL 字段,自动更新 updated_at
|
||||
*/
|
||||
export const updateMonitoredSymbol = `
|
||||
UPDATE monitored_symbols
|
||||
SET enabled = COALESCE($2, enabled),
|
||||
priority = COALESCE($3, priority),
|
||||
label = COALESCE($4, label),
|
||||
notes = COALESCE($5, notes),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, exchange, symbol, interval,
|
||||
enabled, priority, label, notes,
|
||||
created_at, updated_at
|
||||
`;
|
||||
|
||||
// ============================================================
|
||||
// 交易所配置查询 — exchange_config
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 查询所有启用的交易所配置
|
||||
* 返回类型:ExchangeConfigRow[]
|
||||
*/
|
||||
export const queryEnabledExchanges = `
|
||||
SELECT id, exchange,
|
||||
rest_url, ws_url, ws_ping_interval_ms,
|
||||
rate_limit_per_sec, max_reconnect_attempts, reconnect_delay_ms,
|
||||
enabled, notes, created_at, updated_at
|
||||
FROM exchange_config
|
||||
WHERE enabled = TRUE
|
||||
ORDER BY exchange
|
||||
`;
|
||||
|
||||
/**
|
||||
* 查询单个交易所配置
|
||||
* 返回类型:ExchangeConfigRow | null
|
||||
*/
|
||||
export const queryExchangeConfig = `
|
||||
SELECT id, exchange,
|
||||
rest_url, ws_url, ws_ping_interval_ms,
|
||||
rate_limit_per_sec, max_reconnect_attempts, reconnect_delay_ms,
|
||||
enabled, notes, created_at, updated_at
|
||||
FROM exchange_config
|
||||
WHERE exchange = $1
|
||||
`;
|
||||
|
||||
/**
|
||||
* 插入/更新交易所配置(UPSERT)
|
||||
*/
|
||||
export const upsertExchangeConfig = `
|
||||
INSERT INTO exchange_config (
|
||||
exchange, rest_url, ws_url, ws_ping_interval_ms,
|
||||
rate_limit_per_sec, max_reconnect_attempts, reconnect_delay_ms,
|
||||
enabled, notes
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (exchange) DO UPDATE SET
|
||||
rest_url = EXCLUDED.rest_url,
|
||||
ws_url = EXCLUDED.ws_url,
|
||||
ws_ping_interval_ms = EXCLUDED.ws_ping_interval_ms,
|
||||
rate_limit_per_sec = EXCLUDED.rate_limit_per_sec,
|
||||
max_reconnect_attempts = EXCLUDED.max_reconnect_attempts,
|
||||
reconnect_delay_ms = EXCLUDED.reconnect_delay_ms,
|
||||
enabled = EXCLUDED.enabled,
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = NOW()
|
||||
RETURNING id, exchange,
|
||||
rest_url, ws_url, ws_ping_interval_ms,
|
||||
rate_limit_per_sec, max_reconnect_attempts, reconnect_delay_ms,
|
||||
enabled, notes, created_at, updated_at
|
||||
`;
|
||||
|
||||
/**
|
||||
* 按 ID 查询交易所配置
|
||||
* 返回类型:ExchangeConfigRow | null
|
||||
*/
|
||||
export const queryExchangeConfigById = `
|
||||
SELECT id, exchange,
|
||||
rest_url, ws_url, ws_ping_interval_ms,
|
||||
rate_limit_per_sec, max_reconnect_attempts, reconnect_delay_ms,
|
||||
enabled, notes, created_at, updated_at
|
||||
FROM exchange_config
|
||||
WHERE id = $1
|
||||
`;
|
||||
|
||||
/**
|
||||
* 查询所有交易所配置(含已禁用)
|
||||
* 返回类型:ExchangeConfigRow[]
|
||||
*/
|
||||
export const queryAllExchangeConfigs = `
|
||||
SELECT id, exchange,
|
||||
rest_url, ws_url, ws_ping_interval_ms,
|
||||
rate_limit_per_sec, max_reconnect_attempts, reconnect_delay_ms,
|
||||
enabled, notes, created_at, updated_at
|
||||
FROM exchange_config
|
||||
ORDER BY exchange
|
||||
`;
|
||||
|
||||
/**
|
||||
* 按 ID 删除交易所配置(硬删除)
|
||||
* 返回被删除记录的 id
|
||||
*/
|
||||
export const deleteExchangeConfig = `
|
||||
DELETE FROM exchange_config
|
||||
WHERE id = $1
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
/**
|
||||
* 按交易所标识删除配置(硬删除)
|
||||
* 返回被删除记录的 id
|
||||
*/
|
||||
export const deleteExchangeConfigByExchange = `
|
||||
DELETE FROM exchange_config
|
||||
WHERE exchange = $1
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
/**
|
||||
* 更新交易所配置(部分字段)
|
||||
* 仅更新传入的非 NULL 字段,自动更新 updated_at
|
||||
*/
|
||||
export const updateExchangeConfig = `
|
||||
UPDATE exchange_config
|
||||
SET rest_url = COALESCE($2, rest_url),
|
||||
ws_url = COALESCE($3, ws_url),
|
||||
ws_ping_interval_ms = COALESCE($4, ws_ping_interval_ms),
|
||||
rate_limit_per_sec = COALESCE($5, rate_limit_per_sec),
|
||||
max_reconnect_attempts = COALESCE($6, max_reconnect_attempts),
|
||||
reconnect_delay_ms = COALESCE($7, reconnect_delay_ms),
|
||||
enabled = COALESCE($8, enabled),
|
||||
notes = COALESCE($9, notes),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, exchange,
|
||||
rest_url, ws_url, ws_ping_interval_ms,
|
||||
rate_limit_per_sec, max_reconnect_attempts, reconnect_delay_ms,
|
||||
enabled, notes, created_at, updated_at
|
||||
`;
|
||||
|
||||
// ============================================================
|
||||
// 全局配置查询 — app_config
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 查询所有应用配置
|
||||
* 返回类型:AppConfigRow[]
|
||||
*/
|
||||
export const queryAllAppConfig = `
|
||||
SELECT id, key, value, description, updated_at
|
||||
FROM app_config
|
||||
ORDER BY key
|
||||
`;
|
||||
|
||||
/**
|
||||
* 查询单个配置项
|
||||
* 返回类型:AppConfigRow | null
|
||||
*/
|
||||
export const queryAppConfig = `
|
||||
SELECT id, key, value, description, updated_at
|
||||
FROM app_config
|
||||
WHERE key = $1
|
||||
`;
|
||||
|
||||
/**
|
||||
* 设置配置项(UPSERT)
|
||||
*/
|
||||
export const upsertAppConfig = `
|
||||
INSERT INTO app_config (key, value, description)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
value = EXCLUDED.value,
|
||||
description = EXCLUDED.description,
|
||||
updated_at = NOW()
|
||||
RETURNING id, key, value, description, updated_at
|
||||
`;
|
||||
|
||||
/**
|
||||
* 按 ID 查询应用配置
|
||||
* 返回类型:AppConfigRow | null
|
||||
*/
|
||||
export const queryAppConfigById = `
|
||||
SELECT id, key, value, description, updated_at
|
||||
FROM app_config
|
||||
WHERE id = $1
|
||||
`;
|
||||
|
||||
/**
|
||||
* 按 key 删除应用配置(硬删除)
|
||||
* 返回被删除记录的 id
|
||||
*/
|
||||
export const deleteAppConfig = `
|
||||
DELETE FROM app_config
|
||||
WHERE key = $1
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
/**
|
||||
* 按 ID 删除应用配置(硬删除)
|
||||
* 返回被删除记录的 id
|
||||
*/
|
||||
export const deleteAppConfigById = `
|
||||
DELETE FROM app_config
|
||||
WHERE id = $1
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
/**
|
||||
* 更新应用配置值(部分字段)
|
||||
* 仅更新传入的非 NULL 字段,自动更新 updated_at
|
||||
*/
|
||||
export const updateAppConfig = `
|
||||
UPDATE app_config
|
||||
SET value = COALESCE($2, value),
|
||||
description = COALESCE($3, description),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, key, value, description, updated_at
|
||||
`;
|
||||
|
||||
// ============================================================
|
||||
// 复合查询:采集服务启动加载项
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 一次性加载所有启动配置:
|
||||
* 1. 启用的监控标的列表
|
||||
* 2. 对应交易所的连接配置
|
||||
*
|
||||
* 返回两表 JOIN 结果,供采集服务初始化 WebSocket 连接池。
|
||||
*/
|
||||
export const queryStreamSubscriptions = `
|
||||
SELECT
|
||||
m.exchange,
|
||||
m.symbol,
|
||||
m.interval,
|
||||
m.priority,
|
||||
e.rest_url,
|
||||
e.ws_url,
|
||||
e.ws_ping_interval_ms,
|
||||
e.rate_limit_per_sec,
|
||||
e.max_reconnect_attempts,
|
||||
e.reconnect_delay_ms
|
||||
FROM monitored_symbols m
|
||||
JOIN exchange_config e ON m.exchange = e.exchange
|
||||
WHERE m.enabled = TRUE
|
||||
AND e.enabled = TRUE
|
||||
ORDER BY m.exchange, m.priority DESC, m.symbol, m.interval
|
||||
`;
|
||||
@@ -0,0 +1,249 @@
|
||||
// ============================================================
|
||||
// schema/types.ts — PostgreSQL 表对应的 TypeScript 类型定义
|
||||
// ============================================================
|
||||
// 与 data/schema/*.sql 中的表结构一一对应
|
||||
//
|
||||
// 类型约定:
|
||||
// NUMERIC(20,8) → string (保留精度,避免 IEEE 754 浮点误差)
|
||||
// TIMESTAMPTZ → Date (pg 默认解析为 Date)
|
||||
// SERIAL / INT → number
|
||||
// SMALLINT → number
|
||||
// REAL → number
|
||||
// BOOLEAN → boolean
|
||||
// ============================================================
|
||||
|
||||
// ============================================================
|
||||
// 联合类型:约束 TEXT 字段的合法值
|
||||
// ============================================================
|
||||
|
||||
/** 支持的交易所标识 */
|
||||
export type Exchange = "binance" | "okx" | "bybit";
|
||||
|
||||
/** K 线周期(原始 1m + 聚合派生) */
|
||||
export type KlineInterval = "1m" | "5m" | "15m" | "1h" | "4h" | "1d";
|
||||
|
||||
/** 日志级别 */
|
||||
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
||||
|
||||
// ============================================================
|
||||
// 1. K 线主表 — klines
|
||||
// ============================================================
|
||||
|
||||
/** klines 表完整行类型 */
|
||||
export interface KlineRow {
|
||||
/** K 线开盘时间(UTC) */
|
||||
time: Date;
|
||||
/** 交易所 */
|
||||
exchange: Exchange;
|
||||
/** 交易对,如 BTCUSDT */
|
||||
symbol: string;
|
||||
/** K 线周期 */
|
||||
interval: KlineInterval;
|
||||
/** 开盘价 */
|
||||
open: string;
|
||||
/** 最高价 */
|
||||
high: string;
|
||||
/** 最低价 */
|
||||
low: string;
|
||||
/** 收盘价 */
|
||||
close: string;
|
||||
/** 成交量(基准币种) */
|
||||
volume: string;
|
||||
/** 成交额(计价币种) */
|
||||
quote_volume: string;
|
||||
/** 主动买入量(基准币种) */
|
||||
taker_buy_base_vol: string;
|
||||
/** 主动买入额(计价币种) */
|
||||
taker_buy_quote_vol: string;
|
||||
/** 成交笔数 */
|
||||
trade_count: number;
|
||||
/** K 线是否已闭合 */
|
||||
is_closed: boolean;
|
||||
/** 记录创建时间 */
|
||||
created_at: Date;
|
||||
/** 记录更新时间 */
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/** klines 表插入类型(省略自动生成的元数据列) */
|
||||
export interface KlineInsert {
|
||||
time: Date;
|
||||
exchange: Exchange;
|
||||
symbol: string;
|
||||
interval: KlineInterval;
|
||||
open: string;
|
||||
high: string;
|
||||
low: string;
|
||||
close: string;
|
||||
volume: string;
|
||||
quote_volume?: string;
|
||||
taker_buy_base_vol?: string;
|
||||
taker_buy_quote_vol?: string;
|
||||
trade_count?: number;
|
||||
is_closed?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. 连续聚合视图 — klines_5m / klines_15m / klines_1h / klines_1d
|
||||
// ============================================================
|
||||
|
||||
/** 聚合 K 线通用类型(OHLCV,无扩展字段) */
|
||||
export interface AggregatedKlineRow {
|
||||
time: Date;
|
||||
exchange: Exchange;
|
||||
symbol: string;
|
||||
interval: KlineInterval;
|
||||
open: string;
|
||||
high: string;
|
||||
low: string;
|
||||
close: string;
|
||||
volume: string;
|
||||
quote_volume: string;
|
||||
taker_buy_base_vol: string;
|
||||
taker_buy_quote_vol: string;
|
||||
trade_count: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. 监控交易对配置 — monitored_symbols
|
||||
// ============================================================
|
||||
|
||||
/** monitored_symbols 表完整行类型 */
|
||||
export interface MonitoredSymbolRow {
|
||||
/** 自增主键 */
|
||||
id: number;
|
||||
/** 交易所 */
|
||||
exchange: Exchange;
|
||||
/** 交易对 */
|
||||
symbol: string;
|
||||
/** K 线周期 */
|
||||
interval: KlineInterval;
|
||||
/** 是否启用采集 */
|
||||
enabled: boolean;
|
||||
/** 优先级(0-32767,越大越优先) */
|
||||
priority: number;
|
||||
/** 人类可读标签 */
|
||||
label: string | null;
|
||||
/** 备注 */
|
||||
notes: string | null;
|
||||
/** 创建时间 */
|
||||
created_at: Date;
|
||||
/** 更新时间 */
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/** monitored_symbols 插入类型 */
|
||||
export interface MonitoredSymbolInsert {
|
||||
exchange: Exchange;
|
||||
symbol: string;
|
||||
interval: KlineInterval;
|
||||
enabled?: boolean;
|
||||
priority?: number;
|
||||
label?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
/** monitored_symbols 更新类型(所有字段可选) */
|
||||
export interface MonitoredSymbolUpdate {
|
||||
enabled?: boolean;
|
||||
priority?: number;
|
||||
label?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 4. 交易所连接配置 — exchange_config
|
||||
// ============================================================
|
||||
|
||||
/** exchange_config 表完整行类型 */
|
||||
export interface ExchangeConfigRow {
|
||||
/** 自增主键 */
|
||||
id: number;
|
||||
/** 交易所标识(唯一) */
|
||||
exchange: Exchange;
|
||||
/** REST API 基础 URL(null = 使用 SDK 默认值) */
|
||||
rest_url: string | null;
|
||||
/** WebSocket 基础 URL(null = 使用 SDK 默认值) */
|
||||
ws_url: string | null;
|
||||
/** 心跳间隔(毫秒) */
|
||||
ws_ping_interval_ms: number;
|
||||
/** 每秒最大请求数 */
|
||||
rate_limit_per_sec: number;
|
||||
/** 最大重连次数 */
|
||||
max_reconnect_attempts: number;
|
||||
/** 重连延迟基数(毫秒) */
|
||||
reconnect_delay_ms: number;
|
||||
/** 是否启用该交易所 */
|
||||
enabled: boolean;
|
||||
/** 备注 */
|
||||
notes: string | null;
|
||||
/** 创建时间 */
|
||||
created_at: Date;
|
||||
/** 更新时间 */
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/** exchange_config 插入类型 */
|
||||
export interface ExchangeConfigInsert {
|
||||
exchange: Exchange;
|
||||
rest_url?: string | null;
|
||||
ws_url?: string | null;
|
||||
ws_ping_interval_ms?: number;
|
||||
rate_limit_per_sec?: number;
|
||||
max_reconnect_attempts?: number;
|
||||
reconnect_delay_ms?: number;
|
||||
enabled?: boolean;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 5. 全局应用配置 — app_config
|
||||
// ============================================================
|
||||
|
||||
/** app_config 表完整行类型 */
|
||||
export interface AppConfigRow {
|
||||
/** 自增主键 */
|
||||
id: number;
|
||||
/** 配置键 */
|
||||
key: string;
|
||||
/** 配置值(统一存储为字符串) */
|
||||
value: string;
|
||||
/** 说明 */
|
||||
description: string | null;
|
||||
/** 更新时间 */
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/** 已知的 app_config 键名 */
|
||||
export type AppConfigKey =
|
||||
| "batch_size"
|
||||
| "flush_interval_ms"
|
||||
| "log_level"
|
||||
| "redis_publish_enabled";
|
||||
|
||||
// ============================================================
|
||||
// 6. 业务聚合类型
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 唯一标识一个 K 线流
|
||||
* 对应 klines / monitored_symbols 的 (exchange, symbol, interval) 组合
|
||||
*/
|
||||
export interface StreamKey {
|
||||
exchange: Exchange;
|
||||
symbol: string;
|
||||
interval: KlineInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集服务启动时加载的完整订阅配置
|
||||
* = monitored_symbols JOIN exchange_config
|
||||
*/
|
||||
export interface StreamSubscription {
|
||||
/** 流标识 */
|
||||
streamKey: StreamKey;
|
||||
/** 优先级 */
|
||||
priority: number;
|
||||
/** 连接配置 */
|
||||
exchangeConfig: ExchangeConfigRow;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// ============================================================
|
||||
// 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>;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"; // 错误状态
|
||||
@@ -221,7 +221,7 @@ ALTER MATERIALIZED VIEW klines_1d SET (timescaledb.compress = true);
|
||||
-- ============================================================
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'TimescaleDB initialization complete.';
|
||||
RAISE NOTICE '001_init.sql — TimescaleDB initialization complete.';
|
||||
RAISE NOTICE 'Hypertable: klines';
|
||||
RAISE NOTICE 'Continuous aggregates: klines_5m, klines_15m, klines_1h, klines_1d';
|
||||
RAISE NOTICE 'Compression: 7 days delay, 90 days retention';
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
-- ============================================================
|
||||
-- 002_config.sql — 配置表初始化
|
||||
-- ============================================================
|
||||
-- Docker Compose 首次启动时在 001_init.sql 之后自动执行
|
||||
-- 挂载路径:./data/init-db:/docker-entrypoint-initdb.d
|
||||
-- 执行顺序:按文件名排序(001 → 002)
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- 1. monitored_symbols — 监控交易对配置
|
||||
-- ============================================================
|
||||
-- 用途:声明数据采集模块需要订阅哪些交易对的 K 线流
|
||||
-- 消费方:WebSocket 行情采集服务启动时读取此表决定订阅列表
|
||||
CREATE TABLE IF NOT EXISTS monitored_symbols (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
-- ---- 标识维度(与 klines 表对齐) ----
|
||||
exchange TEXT NOT NULL, -- 交易所:binance / okx / bybit
|
||||
symbol TEXT NOT NULL, -- 交易对:BTCUSDT / ETHUSDT
|
||||
interval TEXT NOT NULL, -- K 线周期:1m / 5m / 15m / 1h / 4h / 1d
|
||||
|
||||
-- ---- 控制字段 ----
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用采集
|
||||
priority SMALLINT NOT NULL DEFAULT 0, -- 优先级(数值越大越优先,用于限频时取舍)
|
||||
|
||||
-- ---- 备注 ----
|
||||
label TEXT, -- 人类可读标签,如 "BTC/USDT 1分钟线"
|
||||
notes TEXT, -- 备注说明
|
||||
|
||||
-- ---- 元数据 ----
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- 同一 (exchange, symbol, interval) 组合不可重复
|
||||
UNIQUE (exchange, symbol, interval)
|
||||
);
|
||||
|
||||
-- 索引:按启用状态快速筛选
|
||||
CREATE INDEX IF NOT EXISTS idx_monitored_symbols_enabled
|
||||
ON monitored_symbols (enabled, exchange, priority DESC);
|
||||
|
||||
-- 索引:按交易对查找所有周期
|
||||
CREATE INDEX IF NOT EXISTS idx_monitored_symbols_symbol
|
||||
ON monitored_symbols (symbol, interval);
|
||||
|
||||
-- ============================================================
|
||||
-- 2. exchange_config — 交易所连接配置
|
||||
-- ============================================================
|
||||
-- 用途:存储各交易所的 API 端点、限频参数等连接级配置
|
||||
-- ⚠️ 安全提醒:API Key/Secret 不应明文存储于此表,
|
||||
-- 建议通过环境变量或 Vault 注入,此表仅存非敏感参数
|
||||
CREATE TABLE IF NOT EXISTS exchange_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
-- ---- 交易所标识 ----
|
||||
exchange TEXT NOT NULL UNIQUE, -- 交易所:binance / okx / bybit
|
||||
|
||||
-- ---- 连接参数 ----
|
||||
rest_url TEXT, -- REST API 基础 URL(留空则用 SDK 默认值)
|
||||
ws_url TEXT, -- WebSocket 基础 URL(留空则用 SDK 默认值)
|
||||
ws_ping_interval_ms INTEGER NOT NULL DEFAULT 30000, -- 心跳间隔(毫秒)
|
||||
|
||||
-- ---- 限频控制 ----
|
||||
rate_limit_per_sec REAL NOT NULL DEFAULT 20.0, -- 每秒最大请求数
|
||||
max_reconnect_attempts INT NOT NULL DEFAULT 10, -- 最大重连次数
|
||||
reconnect_delay_ms INT NOT NULL DEFAULT 3000, -- 重连延迟基数(指数退避)
|
||||
|
||||
-- ---- 开关 ----
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用该交易所
|
||||
|
||||
-- ---- 备注 ----
|
||||
notes TEXT,
|
||||
|
||||
-- ---- 元数据 ----
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 3. app_config — 全局应用配置(Key-Value)
|
||||
-- ============================================================
|
||||
-- 用途:存储不适合硬编码的运行时参数,如批量写入阈值
|
||||
CREATE TABLE IF NOT EXISTS app_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
key TEXT NOT NULL UNIQUE, -- 配置键
|
||||
value TEXT NOT NULL, -- 配置值(统一存为文本,消费方自行解析类型)
|
||||
description TEXT, -- 说明
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 默认配置项(仅首次插入,已存在则跳过)
|
||||
INSERT INTO app_config (key, value, description) VALUES
|
||||
('batch_size', '500', '批量写入缓冲区条数阈值'),
|
||||
('flush_interval_ms', '1000', '缓冲区最大等待时间(毫秒)'),
|
||||
('log_level', 'info', '日志级别:trace / debug / info / warn / error'),
|
||||
('redis_publish_enabled', 'true', '是否启用 Redis 发布')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 预置种子数据
|
||||
-- ============================================================
|
||||
|
||||
-- 预置 Binance 主力交易对监控配置(仅首次插入)
|
||||
INSERT INTO monitored_symbols (exchange, symbol, interval, enabled, priority, label) VALUES
|
||||
-- Binance 主力交易对 — 1m
|
||||
('binance', 'BTCUSDT', '1m', TRUE, 10, 'BTC/USDT 1分钟线'),
|
||||
('binance', 'ETHUSDT', '1m', TRUE, 9, 'ETH/USDT 1分钟线'),
|
||||
('binance', 'SOLUSDT', '1m', TRUE, 8, 'SOL/USDT 1分钟线'),
|
||||
('binance', 'BNBUSDT', '1m', TRUE, 7, 'BNB/USDT 1分钟线'),
|
||||
|
||||
-- Binance 主力交易对 — 1h(策略用)
|
||||
('binance', 'BTCUSDT', '1h', TRUE, 10, 'BTC/USDT 1小时线'),
|
||||
('binance', 'ETHUSDT', '1h', TRUE, 9, 'ETH/USDT 1小时线'),
|
||||
('binance', 'SOLUSDT', '1h', TRUE, 8, 'SOL/USDT 1小时线'),
|
||||
|
||||
-- Binance 主力交易对 — 1d(日线)
|
||||
('binance', 'BTCUSDT', '1d', TRUE, 10, 'BTC/USDT 日线'),
|
||||
('binance', 'ETHUSDT', '1d', TRUE, 9, 'ETH/USDT 日线')
|
||||
ON CONFLICT (exchange, symbol, interval) DO NOTHING;
|
||||
|
||||
-- 预置交易所默认连接配置(仅首次插入)
|
||||
INSERT INTO exchange_config (exchange, rate_limit_per_sec, notes) VALUES
|
||||
('binance', 20.0, 'Binance 现货 — 默认权重限频 1200/min'),
|
||||
('okx', 10.0, 'OKX 现货 — 默认限频 10/s'),
|
||||
('bybit', 10.0, 'Bybit 现货 — 默认限频 10/s')
|
||||
ON CONFLICT (exchange) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 初始化完成
|
||||
-- ============================================================
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '002_config.sql — Config tables initialized.';
|
||||
RAISE NOTICE 'Tables: monitored_symbols, exchange_config, app_config';
|
||||
RAISE NOTICE 'Seed data: 9 symbols (Binance), 3 exchanges';
|
||||
END $$;
|
||||
Generated
+5320
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import ccxt from "ccxt";
|
||||
import { ExchangeWs } from '../exchanges/binance';
|
||||
|
||||
const exchange = new ExchangeWs({
|
||||
exchangeId: 'binance',
|
||||
symbols: ['BTCUSDT'],
|
||||
interval: '1m',
|
||||
});
|
||||
|
||||
// exchange.on('kline', (data) => {
|
||||
// console.log(data);
|
||||
// });
|
||||
|
||||
exchange.on('ready', () => {
|
||||
console.log(exchange.getState());
|
||||
console.log(exchange.getReadySymbols());
|
||||
});
|
||||
|
||||
const ccxtClient = new ccxt.binance();
|
||||
@@ -0,0 +1,177 @@
|
||||
-- ============================================================
|
||||
-- schema/config.sql — 配置表 DDL(参考副本)
|
||||
-- ============================================================
|
||||
-- 数据库:TimescaleDB (PostgreSQL 17)
|
||||
-- 说明:管理系统配置、监控标的、交易所连接参数
|
||||
--
|
||||
-- ⚠️ 权威初始化脚本已迁移至:data/init-db/001_init.sql
|
||||
-- 本文件保留作为 pg.initSchema() 非 Docker 部署的回退方案和文档参考。
|
||||
-- 修改表结构时请同步更新 001_init.sql。
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- 1. monitored_symbols — 监控交易对配置
|
||||
-- ============================================================
|
||||
-- 用途:声明数据采集模块需要订阅哪些交易对的 K 线流
|
||||
-- 消费方:WebSocket 行情采集服务启动时读取此表决定订阅列表
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS monitored_symbols (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
-- ---- 标识维度(与 klines 表对齐) ----
|
||||
exchange TEXT NOT NULL, -- 交易所:binance / okx / bybit
|
||||
symbol TEXT NOT NULL, -- 交易对:BTCUSDT / ETHUSDT
|
||||
interval TEXT NOT NULL, -- K 线周期:1m / 5m / 15m / 1h / 4h / 1d
|
||||
|
||||
-- ---- 控制字段 ----
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用采集
|
||||
priority SMALLINT NOT NULL DEFAULT 0, -- 优先级(数值越大越优先,用于限频时取舍)
|
||||
|
||||
-- ---- 备注 ----
|
||||
label TEXT, -- 人类可读标签,如 "BTC/USDT 1分钟线"
|
||||
notes TEXT, -- 备注说明
|
||||
|
||||
-- ---- 元数据 ----
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- 同一 (exchange, symbol, interval) 组合不可重复
|
||||
UNIQUE (exchange, symbol, interval)
|
||||
);
|
||||
|
||||
-- 索引:按启用状态快速筛选
|
||||
CREATE INDEX IF NOT EXISTS idx_monitored_symbols_enabled
|
||||
ON monitored_symbols (enabled, exchange, priority DESC);
|
||||
|
||||
-- 索引:按交易对查找所有周期
|
||||
CREATE INDEX IF NOT EXISTS idx_monitored_symbols_symbol
|
||||
ON monitored_symbols (symbol, interval);
|
||||
|
||||
COMMENT ON TABLE monitored_symbols IS '监控交易对配置表:声明哪些 (交易所,交易对,周期) 需要采集 K 线数据';
|
||||
COMMENT ON COLUMN monitored_symbols.exchange IS '交易所标识:binance / okx / bybit';
|
||||
COMMENT ON COLUMN monitored_symbols.symbol IS '交易对:BTCUSDT / ETHUSDT';
|
||||
COMMENT ON COLUMN monitored_symbols.interval IS 'K 线周期:1m / 5m / 15m / 1h / 4h / 1d';
|
||||
COMMENT ON COLUMN monitored_symbols.enabled IS '是否启用 WebSocket 订阅';
|
||||
COMMENT ON COLUMN monitored_symbols.priority IS '优先级(0-32767),限频时高优先级交易对优先保留';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 2. exchange_config — 交易所连接配置
|
||||
-- ============================================================
|
||||
-- 用途:存储各交易所的 API 端点、限频参数等连接级配置
|
||||
-- 安全提醒:API Key/Secret 不应明文存储于此表,
|
||||
-- 建议通过环境变量或 Vault 注入,此表仅存非敏感参数
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS exchange_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
-- ---- 交易所标识 ----
|
||||
exchange TEXT NOT NULL UNIQUE, -- 交易所:binance / okx / bybit
|
||||
|
||||
-- ---- 连接参数 ----
|
||||
rest_url TEXT, -- REST API 基础 URL(留空则用 SDK 默认值)
|
||||
ws_url TEXT, -- WebSocket 基础 URL(留空则用 SDK 默认值)
|
||||
ws_ping_interval_ms INTEGER NOT NULL DEFAULT 30000, -- 心跳间隔(毫秒)
|
||||
|
||||
-- ---- 限频控制 ----
|
||||
rate_limit_per_sec REAL NOT NULL DEFAULT 20.0, -- 每秒最大请求数
|
||||
max_reconnect_attempts INT NOT NULL DEFAULT 10, -- 最大重连次数
|
||||
reconnect_delay_ms INT NOT NULL DEFAULT 3000, -- 重连延迟基数(指数退避)
|
||||
|
||||
-- ---- 开关 ----
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用该交易所
|
||||
|
||||
-- ---- 备注 ----
|
||||
notes TEXT,
|
||||
|
||||
-- ---- 元数据 ----
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE exchange_config IS '交易所连接配置表:REST/WS 端点、限频、重连策略';
|
||||
COMMENT ON COLUMN exchange_config.rest_url IS 'REST API 地址,空则使用 SDK 默认';
|
||||
COMMENT ON COLUMN exchange_config.ws_url IS 'WebSocket 地址,空则使用 SDK 默认';
|
||||
COMMENT ON COLUMN exchange_config.rate_limit_per_sec IS '每秒最大请求数(Binance 默认 20/s)';
|
||||
COMMENT ON COLUMN exchange_config.max_reconnect_attempts IS 'WebSocket 断线最大重连次数';
|
||||
COMMENT ON COLUMN exchange_config.reconnect_delay_ms IS '重连退避基数(实际延迟 = 基数 × 2^attempts)';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 3. app_config — 全局应用配置(Key-Value)
|
||||
-- ============================================================
|
||||
-- 用途:存储不适合硬编码的运行时参数,如批量写入阈值
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS app_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
key TEXT NOT NULL UNIQUE, -- 配置键
|
||||
value TEXT NOT NULL, -- 配置值(统一存为文本,消费方自行解析类型)
|
||||
description TEXT, -- 说明
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE app_config IS '全局应用配置(KV 结构),运行时参数集中管理';
|
||||
|
||||
-- 默认配置项
|
||||
INSERT INTO app_config (key, value, description) VALUES
|
||||
('batch_size', '500', '批量写入缓冲区条数阈值'),
|
||||
('flush_interval_ms', '1000', '缓冲区最大等待时间(毫秒)'),
|
||||
('log_level', 'info', '日志级别:trace / debug / info / warn / error'),
|
||||
('redis_publish_enabled', 'true', '是否启用 Redis 发布')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 初始数据:预置常见交易对的监控配置
|
||||
-- ============================================================
|
||||
-- 以下为建议的默认监控列表,可根据实际需求增删
|
||||
INSERT INTO monitored_symbols (exchange, symbol, interval, enabled, priority, label) VALUES
|
||||
-- Binance 主力交易对 — 1m
|
||||
('binance', 'BTCUSDT', '1m', TRUE, 10, 'BTC/USDT 1分钟线'),
|
||||
('binance', 'ETHUSDT', '1m', TRUE, 9, 'ETH/USDT 1分钟线'),
|
||||
('binance', 'SOLUSDT', '1m', TRUE, 8, 'SOL/USDT 1分钟线'),
|
||||
('binance', 'BNBUSDT', '1m', TRUE, 7, 'BNB/USDT 1分钟线'),
|
||||
|
||||
-- Binance 主力交易对 — 1h(策略用)
|
||||
('binance', 'BTCUSDT', '1h', TRUE, 10, 'BTC/USDT 1小时线'),
|
||||
('binance', 'ETHUSDT', '1h', TRUE, 9, 'ETH/USDT 1小时线'),
|
||||
('binance', 'SOLUSDT', '1h', TRUE, 8, 'SOL/USDT 1小时线'),
|
||||
|
||||
-- Binance 主力交易对 — 1d(日线)
|
||||
('binance', 'BTCUSDT', '1d', TRUE, 10, 'BTC/USDT 日线'),
|
||||
('binance', 'ETHUSDT', '1d', TRUE, 9, 'ETH/USDT 日线')
|
||||
ON CONFLICT (exchange, symbol, interval) DO NOTHING;
|
||||
|
||||
-- 预置交易所默认连接配置
|
||||
INSERT INTO exchange_config (exchange, rate_limit_per_sec, notes) VALUES
|
||||
('binance', 20.0, 'Binance 现货 — 默认权重限频 1200/min'),
|
||||
('okx', 10.0, 'OKX 现货 — 默认限频 10/s'),
|
||||
('bybit', 10.0, 'Bybit 现货 — 默认限频 10/s')
|
||||
ON CONFLICT (exchange) DO NOTHING;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 5. 常用查询示例
|
||||
-- ============================================================
|
||||
|
||||
-- 查询所有启用的监控标的(采集服务启动时使用)
|
||||
-- SELECT exchange, symbol, interval, priority
|
||||
-- FROM monitored_symbols
|
||||
-- WHERE enabled = TRUE
|
||||
-- ORDER BY exchange, priority DESC, symbol, interval;
|
||||
|
||||
-- 查询某交易所下所有交易对
|
||||
-- SELECT DISTINCT symbol
|
||||
-- FROM monitored_symbols
|
||||
-- WHERE exchange = 'binance' AND enabled = TRUE
|
||||
-- ORDER BY symbol;
|
||||
|
||||
-- 禁用某个交易对的采集
|
||||
-- UPDATE monitored_symbols SET enabled = FALSE, updated_at = NOW()
|
||||
-- WHERE exchange = 'binance' AND symbol = 'BTCUSDT' AND interval = '1m';
|
||||
|
||||
-- 新增监控标的(动态添加,无需重启)
|
||||
-- INSERT INTO monitored_symbols (exchange, symbol, interval, enabled, priority, label)
|
||||
-- VALUES ('binance', 'DOGEUSDT', '1m', TRUE, 5, 'DOGE/USDT 1分钟线')
|
||||
-- ON CONFLICT (exchange, symbol, interval) DO UPDATE
|
||||
-- SET enabled = TRUE, updated_at = NOW();
|
||||
@@ -0,0 +1,206 @@
|
||||
-- ============================================================
|
||||
-- schema/klines.sql — K 线表 DDL(参考副本)
|
||||
-- ============================================================
|
||||
-- 数据库:TimescaleDB (PostgreSQL 17 + timescaledb 2.x)
|
||||
-- 说明:存储全交易所 OHLCV 数据,按时间自动分区压缩
|
||||
--
|
||||
-- ⚠️ 权威初始化脚本:data/init-db/001_init.sql
|
||||
-- 本文件保留作为 pg.initSchema() 非 Docker 部署的回退方案和文档参考。
|
||||
-- 修改表结构时请同步更新 001_init.sql。
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- 1. klines — K 线主表(hypertable)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS klines (
|
||||
-- ---- 时间维度 ----
|
||||
time TIMESTAMPTZ NOT NULL, -- K 线开盘时间(UTC)
|
||||
|
||||
-- ---- 标识维度 ----
|
||||
exchange TEXT NOT NULL, -- 交易所:binance / okx / bybit
|
||||
symbol TEXT NOT NULL, -- 交易对:BTCUSDT / ETHUSDT
|
||||
interval TEXT NOT NULL, -- 周期:1m / 5m / 15m / 1h / 4h / 1d
|
||||
|
||||
-- ---- OHLCV 核心数据 ----
|
||||
open NUMERIC(20,8) NOT NULL,
|
||||
high NUMERIC(20,8) NOT NULL,
|
||||
low NUMERIC(20,8) NOT NULL,
|
||||
close NUMERIC(20,8) NOT NULL,
|
||||
volume NUMERIC(20,8) NOT NULL DEFAULT 0, -- 成交量(基准币种)
|
||||
|
||||
-- ---- 扩展字段 ----
|
||||
quote_volume NUMERIC(20,8) DEFAULT 0, -- 成交额(计价币种)
|
||||
taker_buy_base_vol NUMERIC(20,8) DEFAULT 0, -- 主动买入量(基准币种)
|
||||
taker_buy_quote_vol NUMERIC(20,8) DEFAULT 0, -- 主动买入额(计价币种)
|
||||
trade_count INTEGER DEFAULT 0, -- 成交笔数
|
||||
is_closed BOOLEAN DEFAULT TRUE, -- K 线是否已闭合
|
||||
|
||||
-- ---- 元数据 ----
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
-- 唯一约束:同一根 K 线不可重复
|
||||
UNIQUE (time, exchange, symbol, interval)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 转换为 TimescaleDB hypertable
|
||||
-- ============================================================
|
||||
-- 按 time 列做 1 天分区,按 exchange 做 4 空间分区
|
||||
SELECT create_hypertable(
|
||||
'klines',
|
||||
'time',
|
||||
chunk_time_interval => INTERVAL '1 day',
|
||||
partitioning_column => 'exchange',
|
||||
number_partitions => 4,
|
||||
if_not_exists => TRUE
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 3. 索引设计
|
||||
-- ============================================================
|
||||
|
||||
-- 主力查询索引(覆盖 95% 查询场景)
|
||||
-- 用途:按交易对+周期+时间范围查询最新 K 线
|
||||
CREATE INDEX IF NOT EXISTS idx_klines_lookup
|
||||
ON klines (exchange, symbol, interval, time DESC);
|
||||
|
||||
-- 回测专用索引
|
||||
-- 用途:按交易对+周期+时间正序遍历(策略回测)
|
||||
CREATE INDEX IF NOT EXISTS idx_klines_backtest
|
||||
ON klines (symbol, interval, time ASC);
|
||||
|
||||
-- 最新已闭合 K 线索引(部分索引,减小体积)
|
||||
-- 用途:获取已完成的最新 K 线(避免扫描未闭合数据)
|
||||
CREATE INDEX IF NOT EXISTS idx_klines_latest
|
||||
ON klines (exchange, symbol, interval, time DESC)
|
||||
WHERE is_closed = TRUE;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 压缩策略(列式压缩,压缩比约 90%)
|
||||
-- ============================================================
|
||||
ALTER TABLE klines SET (
|
||||
timescaledb.compress,
|
||||
timescaledb.compress_segmentby = 'exchange, symbol, interval',
|
||||
timescaledb.compress_orderby = 'time DESC'
|
||||
);
|
||||
|
||||
-- K 线闭合 7 天后自动触发压缩
|
||||
SELECT add_compression_policy('klines', INTERVAL '7 days', if_not_exists => TRUE);
|
||||
|
||||
-- ============================================================
|
||||
-- 5. 数据保留策略
|
||||
-- ============================================================
|
||||
-- 1m 粒度 K 线保留 90 天(更粗粒度由连续聚合视图覆盖)
|
||||
SELECT add_retention_policy('klines', INTERVAL '90 days', if_not_exists => TRUE);
|
||||
|
||||
-- ============================================================
|
||||
-- 6. 连续聚合视图(从 1m 自动派生高周期 K 线)
|
||||
-- ============================================================
|
||||
|
||||
-- ---- 5m K 线 ----
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS klines_5m
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket('5 minutes', time) AS time,
|
||||
exchange,
|
||||
symbol,
|
||||
'5m'::TEXT AS interval,
|
||||
FIRST(open, time) AS open,
|
||||
MAX(high) AS high,
|
||||
MIN(low) AS low,
|
||||
LAST(close, time) AS close,
|
||||
SUM(volume) AS volume,
|
||||
SUM(quote_volume) AS quote_volume,
|
||||
SUM(taker_buy_base_vol) AS taker_buy_base_vol,
|
||||
SUM(taker_buy_quote_vol) AS taker_buy_quote_vol,
|
||||
SUM(trade_count) AS trade_count
|
||||
FROM klines
|
||||
WHERE interval = '1m'
|
||||
GROUP BY time_bucket('5 minutes', time), exchange, symbol;
|
||||
|
||||
-- ---- 15m K 线 ----
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS klines_15m
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket('15 minutes', time) AS time,
|
||||
exchange,
|
||||
symbol,
|
||||
'15m'::TEXT AS interval,
|
||||
FIRST(open, time) AS open,
|
||||
MAX(high) AS high,
|
||||
MIN(low) AS low,
|
||||
LAST(close, time) AS close,
|
||||
SUM(volume) AS volume,
|
||||
SUM(quote_volume) AS quote_volume,
|
||||
SUM(taker_buy_base_vol) AS taker_buy_base_vol,
|
||||
SUM(taker_buy_quote_vol) AS taker_buy_quote_vol,
|
||||
SUM(trade_count) AS trade_count
|
||||
FROM klines
|
||||
WHERE interval = '1m'
|
||||
GROUP BY time_bucket('15 minutes', time), exchange, symbol;
|
||||
|
||||
-- ---- 1h K 线 ----
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS klines_1h
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket('1 hour', time) AS time,
|
||||
exchange,
|
||||
symbol,
|
||||
'1h'::TEXT AS interval,
|
||||
FIRST(open, time) AS open,
|
||||
MAX(high) AS high,
|
||||
MIN(low) AS low,
|
||||
LAST(close, time) AS close,
|
||||
SUM(volume) AS volume,
|
||||
SUM(quote_volume) AS quote_volume,
|
||||
SUM(taker_buy_base_vol) AS taker_buy_base_vol,
|
||||
SUM(taker_buy_quote_vol) AS taker_buy_quote_vol,
|
||||
SUM(trade_count) AS trade_count
|
||||
FROM klines
|
||||
WHERE interval = '1m'
|
||||
GROUP BY time_bucket('1 hour', time), exchange, symbol;
|
||||
|
||||
-- ---- 1d K 线 ----
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS klines_1d
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket('1 day', time) AS time,
|
||||
exchange,
|
||||
symbol,
|
||||
'1d'::TEXT AS interval,
|
||||
FIRST(open, time) AS open,
|
||||
MAX(high) AS high,
|
||||
MIN(low) AS low,
|
||||
LAST(close, time) AS close,
|
||||
SUM(volume) AS volume,
|
||||
SUM(quote_volume) AS quote_volume,
|
||||
SUM(taker_buy_base_vol) AS taker_buy_base_vol,
|
||||
SUM(taker_buy_quote_vol) AS taker_buy_quote_vol,
|
||||
SUM(trade_count) AS trade_count
|
||||
FROM klines
|
||||
WHERE interval = '1m'
|
||||
GROUP BY time_bucket('1 day', time), exchange, symbol;
|
||||
|
||||
-- 连续聚合视图也启用压缩
|
||||
ALTER MATERIALIZED VIEW klines_5m SET (timescaledb.compress = true);
|
||||
ALTER MATERIALIZED VIEW klines_15m SET (timescaledb.compress = true);
|
||||
ALTER MATERIALIZED VIEW klines_1h SET (timescaledb.compress = true);
|
||||
ALTER MATERIALIZED VIEW klines_1d SET (timescaledb.compress = true);
|
||||
|
||||
-- ============================================================
|
||||
-- 7. 常用查询示例
|
||||
-- ============================================================
|
||||
|
||||
-- 查询最新 N 根 1h K 线
|
||||
-- SELECT time, open, high, low, close, volume
|
||||
-- FROM klines_1h
|
||||
-- WHERE exchange = 'binance' AND symbol = 'BTCUSDT'
|
||||
-- ORDER BY time DESC LIMIT 100;
|
||||
|
||||
-- 查询某个时间范围内的原始 1m K 线
|
||||
-- SELECT time, open, high, low, close, volume
|
||||
-- FROM klines
|
||||
-- WHERE exchange = 'binance' AND symbol = 'ETHUSDT' AND interval = '1m'
|
||||
-- AND time BETWEEN '2026-06-01' AND '2026-06-06'
|
||||
-- ORDER BY time ASC;
|
||||
Reference in New Issue
Block a user