feat(data): 实现配置表 CRUD 与 Schema 初始化拆分

- 新增 data/db/ 数据库访问层:pool 管理、类型定义、Zod 校验、参数化 SQL 查询
- 新增 data/db/config-crud.ts:MonitoredSymbolsRepo / ExchangeConfigRepo / AppConfigRepo 三个 CRUD 服务类
- 新增 data/config.ts:中心化配置模块,零依赖 .env 解析 + Zod 校验
- 新增 data/schema/:klines.sql + config.sql 参考 DDL
- 新增 data/exchanges/:交易所类型定义与 Binance WebSocket 封装
- 新增 data/run/:交易所连接启动入口
- 重构 data/init-db/:001_init.sql 仅保留 TimescaleDB + klines,配置表拆分至 002_config.sql
- 更新 docker-compose.yml:挂载 init-db 初始化脚本
This commit is contained in:
Rekey
2026-06-07 20:46:35 +08:00
parent 10e13ae8da
commit e91cad79e6
18 changed files with 8560 additions and 5 deletions
-4
View File
@@ -5,10 +5,6 @@
# cp .env.example .env
# ============================================================
# --- 行情订阅 ---
# 逗号分隔的交易对列表(大写)
SYMBOLS=BTCUSDT,ETHUSDT
# --- TimescaleDB 连接 ---
DB_HOST=localhost
DB_PORT=5432
+184
View File
@@ -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 连接 URLioredis 可直接使用) */
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));
}
+530
View File
@@ -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 — 全局应用配置(KVCRUD
// ============================================================
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;
}
}
+121
View File
@@ -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
View File
@@ -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;
+561
View File
@@ -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
`;
+249
View File
@@ -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 基础 URLnull = 使用 SDK 默认值) */
rest_url: string | null;
/** WebSocket 基础 URLnull = 使用 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;
}
+245
View File
@@ -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-32767SMALLINT 范围) */
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>;
+395
View File
@@ -0,0 +1,395 @@
// ============================================================
// exchanges/binance.ts — 通用交易所 WebSocket 行情采集类
// ============================================================
// 基于 ccxt.pro 实现,不限于 Binance,支持任意 ccxt 支持的交易所。
// 构造即启动:传入交易所 ID + 交易对列表,自动开始 WebSocket 监听。
//
// 使用方式:
// import { ExchangeWs } from "./exchanges/binance";
// const ws = new ExchangeWs({
// exchangeId: "binance",
// symbols: ["BTCUSDT", "ETHUSDT"],
// interval: "1m",
// });
// ws.on("kline", (data) => console.log(data));
// ============================================================
import ccxt from "ccxt";
import { EventEmitter } from "node:events";
import type {
KlineWsData,
ExchangeWsConfig,
WsConnectionState,
} from "./types";
// ============================================================
// 内部类型:ccxt pro 交易所实例的最小接口
// ============================================================
// 避免直接依赖 ccxt 内部类型(不同版本导出签名差异大),
// 仅声明本类实际调用的方法签名。
/** ccxt OHLCV 数组:[timestamp_ms, open, high, low, close, volume] */
type OHLCVCandle = [number, number, number, number, number, number];
/** ccxt.pro 交易所实例的最小接口 */
interface IProExchange {
watchOHLCV(symbol: string, timeframe: string): Promise<OHLCVCandle[]>;
close(): Promise<void>;
}
// ============================================================
// 类型声明:为 EventEmitter 添加严格的事件签名
// ============================================================
export declare interface ExchangeWs {
/** 新 K 线数据到达(含实时更新和闭合 K 线) */
on(event: "kline", listener: (data: KlineWsData) => void): this;
/** WebSocket 错误(非致命,单 symbol 出错不影响其他) */
on(event: "error", listener: (error: ExchangeWsError) => void): this;
/** 连接状态变更 */
on(
event: "stateChange",
listener: (state: WsConnectionState, exchangeId: string) => void,
): this;
/** 单个 symbol 的 watch 循环已启动 */
on(
event: "symbolReady",
listener: (symbol: string, interval: string) => void,
): this;
/** 所有 symbols 已进入监听状态 */
on(event: "ready", listener: () => void): this;
}
/** 带 symbol 上下文的错误类型 */
export class ExchangeWsError extends Error {
symbol: string;
exchangeId: string;
constructor(message: string, exchangeId: string, symbol: string) {
super(message);
this.name = "ExchangeWsError";
this.exchangeId = exchangeId;
this.symbol = symbol;
}
}
// ============================================================
// ExchangeWs — 主类
// ============================================================
export class ExchangeWs extends EventEmitter {
// ---- 配置 ----
private readonly exchangeId: string;
private readonly symbols: string[];
private readonly interval: string;
private readonly ccxtOptions: Record<string, unknown>;
// ---- 运行时状态 ----
private exchange: IProExchange | null = null;
private state: WsConnectionState = "idle";
private abortController: AbortController | null = null;
/** 每个 symbol 一条 watch 循环 */
private watchTasks: Promise<void>[] = [];
/** 已成功启动监听的 symbol 集合 */
private readonly readySymbols = new Set<string>();
// ============================================================
// 构造器:传入参数,自动启动 WebSocket 监听
// ============================================================
/**
* @param config.exchangeId - 交易所 IDbinance / okx / bybit / …)
* @param config.symbols - 要订阅的交易对列表
* @param config.interval - K 线周期,默认 '1m'
* @param config.ccxtOptions - 传递给 ccxt.pro 交易所构造器的额外选项
*
* 构造完成后 WebSocket 连接立即在后台启动,
* 监听 'ready' 事件确认全部就绪,或 'kline' 事件接收数据。
*/
constructor(config: ExchangeWsConfig) {
super();
// 参数校验
if (!config.symbols || config.symbols.length === 0) {
throw new Error("symbols 不能为空");
}
this.exchangeId = config.exchangeId;
this.symbols = [...config.symbols]; // 防御性拷贝
this.interval = config.interval ?? "1m";
this.ccxtOptions = config.ccxtOptions ?? {};
// 构造即启动(fire-and-forget,错误通过 'error' 事件抛出)
this.start().catch((err) => {
this.emit(
"error",
new ExchangeWsError(
`启动失败: ${(err as Error).message}`,
this.exchangeId,
"ALL",
),
);
});
}
// ============================================================
// 公开方法
// ============================================================
/** 获取当前连接状态 */
getState(): WsConnectionState {
return this.state;
}
/** 获取已就绪的 symbol 列表 */
getReadySymbols(): string[] {
return [...this.readySymbols];
}
/**
* 动态添加交易对(已监听的 symbol 会被忽略)
* 可在运行时增删监控标的,无需重启整个连接
*/
async addSymbols(newSymbols: string[]): Promise<void> {
const toAdd = newSymbols.filter((s) => !this.readySymbols.has(s));
if (toAdd.length === 0) {
return;
};
for (const symbol of toAdd) {
this.symbols.push(symbol);
const task = this.watchSymbol(symbol);
this.watchTasks.push(task);
}
}
/**
* 停止 WebSocket 监听并释放资源
* 调用后实例不可复用,需重新 new
*/
async close(): Promise<void> {
// 1. 取消所有 watch 循环
this.abortController?.abort();
// 2. 等待所有 watch 循环退出
await Promise.allSettled(this.watchTasks);
// 3. 关闭 ccxt 交易所连接
if (this.exchange) {
try {
await this.exchange.close();
} catch {
// 忽略关闭时的错误
}
this.exchange = null;
}
this.setState("disconnected");
this.removeAllListeners();
}
// ============================================================
// 内部方法
// ============================================================
/** 异步启动:初始化 ccxt 实例并为每个 symbol 启动 watch 循环 */
private async start(): Promise<void> {
this.abortController = new AbortController();
this.setState("connecting");
// 1. 创建 ccxt.pro 交易所实例
this.exchange = this.createExchange();
// 2. 为每个 symbol 启动独立的 watch 循环
this.watchTasks = this.symbols.map((symbol) => this.watchSymbol(symbol));
// 3. 等待任意 symbol 就绪(或全部失败)
try {
await this.waitForAnyReady();
this.setState("connected");
this.emit("ready");
} catch {
this.setState("error");
this.emit(
"error",
new ExchangeWsError("所有 symbol 连接失败", this.exchangeId, "ALL"),
);
}
}
/** 创建 ccxt.pro 交易所实例 */
private createExchange(): IProExchange {
// 动态获取 ccxt.pro[exchangeId] 构造器
const ccxtAny = ccxt as unknown as Record<string, unknown>;
const proNamespace = ccxtAny.pro as
| Record<string, new (opts?: Record<string, unknown>) => IProExchange>
| undefined;
if (!proNamespace || typeof proNamespace !== "object") {
throw new Error(
"ccxt.pro 不可用,请确认 ccxt 版本 >= 4.0 且已安装 pro 支持",
);
}
const ProExchange = proNamespace[this.exchangeId];
if (!ProExchange) {
throw new Error(
`不支持的交易所: ${this.exchangeId},可用: ${Object.keys(proNamespace).join(", ")}`,
);
}
return new ProExchange({
enableRateLimit: true,
...this.ccxtOptions,
});
}
/** 单个 symbol 的 watch 循环 */
private async watchSymbol(symbol: string): Promise<void> {
const signal = this.abortController!.signal;
// 带退避的重连循环
let consecutiveErrors = 0;
const maxBackoff = 30000; // 最大退避 30s
while (!signal.aborted) {
try {
// 检查是否已取消
if (signal.aborted) break;
// watchOHLCV 返回 Promise<OHLCV[]>,在新数据到达时 resolve
// ccxt 返回格式:[timestamp_ms, open, high, low, close, volume]
const candles = await this.exchange!.watchOHLCV(
symbol,
this.interval,
);
// 重置错误计数(成功获取数据)
consecutiveErrors = 0;
// 标记该 symbol 已就绪
if (!this.readySymbols.has(symbol)) {
this.readySymbols.add(symbol);
this.emit("symbolReady", symbol, this.interval);
}
// 处理返回的 K 线数据
this.processCandles(symbol, candles);
} catch (err) {
if (signal.aborted) break;
consecutiveErrors++;
const delay = Math.min(1000 * 2 ** consecutiveErrors, maxBackoff);
this.emit(
"error",
new ExchangeWsError(
`[${symbol}] ${(err as Error).message}${delay / 1000}s 后重试 (第 ${consecutiveErrors} 次)`,
this.exchangeId,
symbol,
),
);
// 指数退避等待(可被 abort 中断)
await this.sleep(delay, signal);
}
}
}
/** 处理 ccxt watchOHLCV 返回的原始数据 */
private processCandles(symbol: string, candles: OHLCVCandle[]): void {
// candles 是完整的 OHLCV 数组,最后一条是最新数据
// ccxt 会返回增量更新,通常只包含新增的 candle
for (const candle of candles) {
// ccxt OHLCV 格式:[timestamp_ms, open, high, low, close, volume]
if (!Array.isArray(candle) || candle.length < 6) {
continue;
};
const data: KlineWsData = {
exchange: this.exchangeId,
symbol,
interval: this.interval,
time: candle[0],
open: candle[1],
high: candle[2],
low: candle[3],
close: candle[4],
volume: candle[5],
};
this.emit("kline", data);
}
}
/** 等待至少一个 symbol 就绪(或超时/全部失败) */
private async waitForAnyReady(): Promise<void> {
return new Promise((resolve, reject) => {
let resolved = false;
const onReady = () => {
if (!resolved) {
resolved = true;
cleanup();
resolve();
}
};
const onError = (err: ExchangeWsError) => {
// 仅在所有 symbol 都失败时 reject
if (!resolved && err.symbol === "ALL") {
resolved = true;
cleanup();
reject(err);
}
};
const cleanup = () => {
this.off("symbolReady", onReady);
this.off("error", onError);
};
this.once("symbolReady", onReady);
this.once("error", onError);
// 超时保护(30 秒)
setTimeout(() => {
if (!resolved) {
resolved = true;
cleanup();
reject(
new ExchangeWsError("连接超时(30s", this.exchangeId, "ALL"),
);
}
}, 30000);
});
}
/** 带 AbortSignal 的 sleep */
private sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) {
resolve();
return;
}
const timer = setTimeout(resolve, ms);
signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
}
/** 更新状态并发出事件 */
private setState(newState: WsConnectionState): void {
if (this.state !== newState) {
this.state = newState;
this.emit("stateChange", newState, this.exchangeId);
}
}
}
+45
View File
@@ -0,0 +1,45 @@
// ============================================================
// exchanges/types.ts — WebSocket 事件数据类型
// ============================================================
/** 由 WebSocket 推送的单根 K 线数据 */
export interface KlineWsData {
/** 交易所标识 */
exchange: string;
/** 交易对,如 BTCUSDT */
symbol: string;
/** K 线周期,如 1m / 1h / 1d */
interval: string;
/** K 线开盘时间(Unix 毫秒时间戳) */
time: number;
/** 开盘价 */
open: number;
/** 最高价 */
high: number;
/** 最低价 */
low: number;
/** 收盘价 */
close: number;
/** 成交量(基准币种) */
volume: number;
}
/** ExchangeWs 构造参数 */
export interface ExchangeWsConfig {
/** 交易所 ID,ccxt 支持的所有交易所标识 */
exchangeId: string;
/** 要订阅的交易对列表,如 ['BTCUSDT', 'ETHUSDT'] */
symbols: string[];
/** K 线周期,默认 '1m' */
interval?: string;
/** 传递给 ccxt.pro 交易所实例的额外选项(如 agent、apiKey 等) */
ccxtOptions?: Record<string, unknown>;
}
/** ExchangeWs 连接状态 */
export type WsConnectionState =
| "idle" // 尚未启动
| "connecting" // 正在连接 WebSocket
| "connected" // 已连接,正在接收数据
| "disconnected" // 已断开
| "error"; // 错误状态
+1 -1
View File
@@ -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';
+136
View File
@@ -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 $$;
+5320
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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",
+19
View File
@@ -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();
+177
View File
@@ -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();
+206
View File
@@ -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;
+6
View File
@@ -26,3 +26,9 @@ services:
interval: 10s
timeout: 5s
retries: 5
adminer:
image: adminer
restart: always
ports:
- 8080:8080