feat(data): 实现 Binance WebSocket 适配器与架构重构

- 新增 exchanges/ 模块:MarketDataFeed 统一接口、BaseExchangeAdapter 抽象基类、
  BinanceAdapter 完整实现(WebSocket + REST)
- WebSocket 层基于 binance 官方 SDK 的 WebsocketClient,自动多路复用与断线重连
- REST 层使用 MainClient(Spot),实现 fetchKlines 自动分页补拉 + fetchMarkets 元数据解析
- 数据标准化:Ticker/Trade/OrderBook/Kline 类型定义与 Binance 原生格式互转
- 引入 RxJS Subject 作为统一事件流管道,按 eventType 运行时路由分发
- 重构 config/:YAML 驱动配置加载 + 零依赖运行时校验(fail-fast)
- 重构 db/:TypeORM DataSource 配置 + TimescaleDB K 线 Hypertable 实体
- 新增 utils/logger.ts:Pino 结构化日志(开发环境 pino-pretty 彩色输出)
- 新增 env.yaml 作为 TS/Python 共享的统一环境配置源
- 删除旧版手写 SQL schema 与散落配置文件,收敛到 TypeORM 实体管理
- 安装 rxjs@7.8.2 依赖
This commit is contained in:
Rekey
2026-06-08 01:24:48 +08:00
parent e91cad79e6
commit 85a0031a78
31 changed files with 4261 additions and 8757 deletions
-530
View File
@@ -1,530 +0,0 @@
// ============================================================
// 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;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { DataSource } from "typeorm";
import { pgsql } from "../config";
import * as entities from "./entities";
export const AppDataSource = new DataSource({
type: "postgres",
host: pgsql.host,
port: pgsql.port,
database: pgsql.database,
username: pgsql.user,
password: pgsql.password,
// 实体注册:关系实体通过 entities/index.ts 统一导出
// TimescaleDB K 线实体后续通过 @timescaledb/typeorm 装饰器注册
entities: [
...Object.values(entities),
],
// 生产环境禁用 synchronize,使用 Migration
synchronize: true,
migrations: [__dirname + "/migrations/*.{ts,js}"],
// 连接池
extra: {
max: pgsql.max, // 最大连接数 20
idleTimeoutMillis: pgsql.idleTimeoutMillis, // 空闲超时 30s
connectionTimeoutMillis: pgsql.connectionTimeoutMillis, // 连接超时 5s
},
logging: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
await AppDataSource.initialize();
+32
View File
@@ -0,0 +1,32 @@
// ============================================================
// common.entity.ts — 实体公共基类
// ============================================================
// 所有关系实体(TypeORM 管理域)继承此类,统一:
// - id: UUID 主键
// - created_at: 记录创建时间(自动填充)
// - updated_at: 最后更新时间(自动填充)
//
// TimescaleDB K 线实体(@timescaledb/typeorm 管理域)不继承此类,
// 因为它们需要 @TimeColumn() 等特定装饰器。
// ============================================================
import {
BaseEntity,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
export abstract class CommonBaseEntity extends BaseEntity {
/** UUID 主键(非自增整数,便于分布式场景) */
@PrimaryGeneratedColumn("uuid")
id!: string;
/** 记录创建时间 */
@CreateDateColumn({ type: "timestamptz", name: "created_at" })
createdAt!: Date;
/** 最后更新时间(每次 UPDATE 自动刷新) */
@UpdateDateColumn({ type: "timestamptz", name: "updated_at" })
updatedAt!: Date;
}
+41
View File
@@ -0,0 +1,41 @@
// ============================================================
// exchange.entity.ts — 交易所配置实体
// ============================================================
// 映射到 PostgreSQL exchanges 表,存储已接入的交易所元信息。
// 由 TypeORM 管理(关系数据),不与 TimescaleDB 耦合。
//
// 继承 CommonBaseEntityid (UUID) / created_at / updated_at
// ============================================================
import {
Entity,
Column,
OneToMany,
} from "typeorm";
import { CommonBaseEntity } from "./common.entity";
@Entity("exchanges")
export class Exchange extends CommonBaseEntity {
/** 交易所唯一标识(如 binance / okx / bybit */
@Column("varchar", { length: 50, unique: true })
name!: string;
/** 交易所显示名称(如 Binance / OKX / Bybit */
@Column("varchar", { length: 100 })
label!: string;
/** 是否启用该交易所的数据采集 */
@Column("boolean", { default: true })
enabled!: boolean;
/** 交易所特定配置(JSON:费率、最小下单量、API 限频等) */
@Column("jsonb", { nullable: true })
config?: Record<string, unknown>;
/**
* 该交易所下的所有交易对。
* 使用字符串引用避免循环依赖(TradingPair 也引用 Exchange)。
*/
@OneToMany("TradingPair", "exchange")
tradingPairs!: unknown[];
}
+12
View File
@@ -0,0 +1,12 @@
// ============================================================
// entities/index.ts — 实体统一导出
// ============================================================
// TypeORM DataSource 通过 import * as entities from "../entities"
// 自动注册所有实体,无需手动逐个添加。
// ============================================================
export { CommonBaseEntity } from "./common.entity";
export { Exchange } from "./exchange.entity";
export { TradingPair } from "./trading-pair.entity";
export { Kline } from "./kline.entity";
export type { KlineInterval } from "./kline.entity";
+138
View File
@@ -0,0 +1,138 @@
// ============================================================
// kline.entity.ts — TimescaleDB K 线 Hypertable 实体
// ============================================================
// 映射到 PostgreSQL klines 表(TimescaleDB hypertable)。
// 不继承 CommonBaseEntity — 使用 @timescaledb/typeorm 的
// @Hypertable / @TimeColumn 装饰器管理 TimescaleDB 特性。
//
// 关键 TimescaleDB 特性(由 @Hypertable 装饰器自动配置):
// - 自动按 time 列做时间分区(by_range
// - 列式压缩(compress),7 天后自动执行
// - 通过 ContinuousAggregate 生成高周期 K 线视图
//
// 注意:@timescaledb/typeorm v0.0.1 为实验版本,
// 不支持空间分区(partitioning_column)。
// 若需要空间分区,可通过 db/init-db/ 下的 SQL 脚本手动添加。
// ============================================================
import { Hypertable, TimeColumn } from "@timescaledb/typeorm";
import {
Entity,
PrimaryColumn,
Column,
Index,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
/** K 线周期枚举 */
export type KlineInterval =
| "1m"
| "5m"
| "15m"
| "30m"
| "1h"
| "4h"
| "1d"
| "1w";
/**
* 1 分钟 K 线 Hypertable
*
* 存储交易所推送的 OHLCV 数据。写入使用 UPSERT
* ON CONFLICT DO UPDATE),已存在的 K 线只更新
* high/low/close/volume 增量。
*
* 高周期 K 线(5m+)通过 TimescaleDB 连续聚合视图
* 从 1m 表自动生成,无需单独建表。
*/
@Hypertable({
compression: {
compress: true,
compress_orderby: "time DESC",
compress_segmentby: "exchange, symbol, interval",
policy: {
schedule_interval: "365 days", // 365 天后自动压缩
},
},
})
@Index(["exchange", "symbol", "interval", "time"], { unique: true })
@Entity("klines")
export class Kline {
/** K 线开盘时间(UTC)— @timescaledb/typeorm 自动标记为时间分区列 */
@TimeColumn()
@PrimaryColumn("timestamptz")
time!: Date;
/** 交易所标识(binance / okx / bybit */
@Column("text")
exchange!: string;
/** 交易对符号(如 BTCUSDT */
@Column("text")
symbol!: string;
/** K 线周期(1m */
@Column("text")
interval!: KlineInterval;
// ============================================================
// OHLCV 价格数据(NUMERIC(20,8) 精度,与交易所对齐)
// ============================================================
/** 开盘价 */
@Column("numeric", { precision: 20, scale: 8 })
open!: number;
/** 最高价 */
@Column("numeric", { precision: 20, scale: 8 })
high!: number;
/** 最低价 */
@Column("numeric", { precision: 20, scale: 8 })
low!: number;
/** 收盘价 */
@Column("numeric", { precision: 20, scale: 8 })
close!: number;
/** 成交量(base 币种) */
@Column("numeric", { precision: 20, scale: 8 })
volume!: number;
// ============================================================
// 扩展字段(Binance 等交易所提供)
// ============================================================
/** 成交额(quote 币种) */
@Column("numeric", { precision: 20, scale: 8, nullable: true })
quote_volume?: number;
/** 主动买入成交量(base 币种) */
@Column("numeric", { precision: 20, scale: 8, nullable: true })
taker_buy_base_vol?: number;
/** 主动买入成交额(quote 币种) */
@Column("numeric", { precision: 20, scale: 8, nullable: true })
taker_buy_quote_vol?: number;
/** 成交笔数 */
@Column("integer", { nullable: true })
trade_count?: number;
/** K 线是否已关闭(true = 该周期 K 线不再变化) */
@Column("boolean", { default: true })
is_closed!: boolean;
// ============================================================
// 审计字段
// ============================================================
/** 记录首次写入时间 */
@CreateDateColumn({ type: "timestamptz", name: "created_at" })
createdAt!: Date;
/** 记录最后更新时间 */
@UpdateDateColumn({ type: "timestamptz", name: "updated_at" })
updatedAt!: Date;
}
+105
View File
@@ -0,0 +1,105 @@
// ============================================================
// trading-pair.entity.ts — 交易对配置实体
// ============================================================
// 映射到 PostgreSQL trading_pairs 表,存储各交易所的交易对元信息。
// 数据模块启动时从该表读取 active=true 的交易对列表,
// 决定 WebSocket 订阅范围和 K 线合成范围。
//
// 继承 CommonBaseEntityid (UUID) / created_at / updated_at
//
// 与 TimescaleDB klines 表的关系:
// klines.symbol → trading_pairs.symbol(逻辑外键,不做 DB 级约束)
// klines.exchange → exchanges.name(逻辑外键)
// ============================================================
import {
Entity,
Column,
ManyToOne,
JoinColumn,
Index,
} from "typeorm";
import { Exchange } from "./exchange.entity";
import { CommonBaseEntity } from "./common.entity";
@Entity("trading_pairs")
@Index(["exchange", "symbol"], { unique: true }) // 同一交易所下 symbol 唯一
@Index(["active"]) // 按激活状态快速筛选
export class TradingPair extends CommonBaseEntity {
/** 所属交易所 */
@ManyToOne(() => Exchange, { nullable: false })
@JoinColumn({ name: "exchange_id" })
exchange!: Exchange;
/** 交易对符号(如 BTCUSDT / ETHUSDT */
@Column("varchar", { length: 20 })
symbol!: string;
/** 基础币种(如 BTC */
@Column("varchar", { length: 10 })
base_asset!: string;
/** 计价币种(如 USDT */
@Column("varchar", { length: 10 })
quote_asset!: string;
/** 价格精度(小数位数) */
@Column("integer", { default: 10 })
price_precision!: number;
/** 数量精度(小数位数) */
@Column("integer", { default: 10 })
quantity_precision!: number;
/** 最小下单量 */
@Column("numeric", { precision: 32, scale: 8, nullable: true })
min_qty?: number;
/** 下单步长(数量增量) */
@Column("numeric", { precision: 32, scale: 8, nullable: true })
step_size?: number;
/** 最小名义价值(USDT */
@Column("numeric", { precision: 32, scale: 8, nullable: true })
min_notional?: number;
/** 是否激活数据订阅(false 时不采集该交易对行情) */
@Column("boolean", { default: true })
active!: boolean;
/** 是否启用 K 线合成(false 时仅采集原始行情,不合成) */
@Column("boolean", { default: true })
kline_synthesis_enabled!: boolean;
/** K 线合成周期列表(逗号分隔,如 "1m,5m,15m,1h,4h,1d" */
@Column("varchar", { length: 100, default: "1m,5m,15m,1h,4h,1d" })
kline_intervals!: string;
/**
* 历史 K 线最后补全时间(UTC)。
* 记录最近一次 REST 补拉 K 线的结束时间戳,
* 下次启动时从此时间点继续补全,避免重复拉取。
*
* 默认值为 Unix 01970-01-01T00:00:00.000Z),
* 新交易对从 epoch 起始时间开始全量补拉,
* 补全后更新为实际拉取到的最后时间。
*/
@Column("timestamptz", { default: () => "to_timestamp(0)" })
last_backfill_time!: Date;
/** 备注 */
@Column("text", { nullable: true })
notes?: string;
// ============================================================
// 工具方法
// ============================================================
/** 解析 kline_intervals 为周期数组 */
getIntervals(): string[] {
return this.kline_intervals
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
}
-121
View File
@@ -1,121 +0,0 @@
// ============================================================
// 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
@@ -1,364 +0,0 @@
// ============================================================
// 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
@@ -1,561 +0,0 @@
// ============================================================
// 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
@@ -1,249 +0,0 @@
// ============================================================
// 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
@@ -1,245 +0,0 @@
// ============================================================
// 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>;