feat: 接入 USDT-M 合约数据 — type 字段方案
- PairType 定义移至 types/kline.ts (spot/um/cm) - Kline 接口新增 type 字段,全链路透传 - klines 5列复合主键 (exchange, symbol, type, interval, time) - 拆出 BinanceFuturesRestClient (USDMClient) - exchanges/index.ts 注册 binance_futures - trading_pairs 唯一约束加 type,种子数据加合约对 - 12个连续聚合视图 SELECT/GROUP BY/INDEX 加 type - 清理 bnkline.ts 废弃代码和 pair.ts 空函数
This commit is contained in:
+239
-138
@@ -1,198 +1,299 @@
|
||||
# 接入 USDT-M 合约数据 — 改造方案
|
||||
# 接入 USDT-M 合约数据 — 改造方案(type 字段方案)
|
||||
|
||||
## 设计思路
|
||||
|
||||
**用 symbol 后缀区分账户类型**,而非新增 `account_type` 列。核心原则是尽量不动已有表结构和聚合视图。
|
||||
**用 `type` 列区分账户类型**,而非 symbol 后缀。`PairType` 枚举已在代码中定义(`'spot' | 'um' | 'cm'`),本次改造将 `type` 从实体层的死代码变成全链路透传的一等字段。
|
||||
|
||||
### Symbol 命名约定
|
||||
### 为什么选 type 而非 .P 后缀
|
||||
|
||||
| 账户类型 | symbol 示例 | 说明 |
|
||||
|---------|-------------|------|
|
||||
| 现货 | `BTCUSDT` | 不变 |
|
||||
| USDT-M 永续 | `BTCUSDT.P` | `.P` 后缀标记合约 |
|
||||
| Coin-M 永续 | `BTCUSDT_PERP` | 预留 |
|
||||
| | `.P` 后缀 | `type` 字段 |
|
||||
|---|---|---|
|
||||
| symbol 语义 | 污染标识符,BTCUSDT.P 不是交易对名称 | 干净,symbol 与 Binance 官方一致 |
|
||||
| 查询 | `WHERE symbol LIKE '%.P'` 不走索引 | `WHERE type = 'um'` 索引友好 |
|
||||
| 扩展 | Coin-M 需要 `_PERP` 新后缀规则 | 加 `'cm'` 枚举值即可 |
|
||||
| API 调用 | 每次 strip 后缀,容易遗漏 | symbol 原样传入,零变换 |
|
||||
| 与已有代码 | 与 TradingPair.type 字段矛盾 | 与已有 PairType、实体定义完全对齐 |
|
||||
|
||||
**核心机制**:对外(入库、查询、展示)统一用带后缀的 symbol;对内(调用 Binance SDK)自动 strip 后缀。
|
||||
### Type 枚举
|
||||
|
||||
| type | 说明 |
|
||||
|------|------|
|
||||
| `spot` | 现货(默认值) |
|
||||
| `um` | USDT-M 永续合约 |
|
||||
| `cm` | Coin-M 永续合约(预留) |
|
||||
|
||||
### K 线复合主键
|
||||
|
||||
5 列:`(exchange, symbol, type, interval, time)`。`type` 在主键中保证相同 symbol 的现货与合约 K 线不会 PK 冲突。
|
||||
|
||||
---
|
||||
|
||||
## 改动清单
|
||||
|
||||
### 1. 配置层(3 文件)
|
||||
### 1. 配置层(已完成,无需再改)
|
||||
|
||||
**`data/env.yaml`**
|
||||
```yaml
|
||||
exchange:
|
||||
binance: ← 保留不动(向后兼容)
|
||||
api_key: "..."
|
||||
api_secret: "..."
|
||||
binance_futures: ← 新增
|
||||
api_key: "..."
|
||||
api_secret: "..."
|
||||
```
|
||||
|
||||
**`data/config/validators.ts`**
|
||||
- `ExchangeConfig` 接口中 `binance: ExchangeApiKeys` 保持不动
|
||||
- 新增 `binance_futures: ExchangeApiKeys`
|
||||
- `validateConfig()` 中新增解析 `exchange.binance_futures`
|
||||
|
||||
**`data/config/index.ts`**
|
||||
- 导出 `exchange.binance`(已有)+ 新增 `exchange.binanceFutures`
|
||||
`data/env.yaml`、`data/config/validators.ts`、`data/config/index.ts` 已有 `binance_futures` 段,无需改动。
|
||||
|
||||
---
|
||||
|
||||
### 2. REST 客户端(1 文件,核心改动)
|
||||
### 2. 类型定义(1 文件)
|
||||
|
||||
**`data/exchanges/rest.ts`**
|
||||
**`data/types/base.ts`** — Kline 接口加 `type` 字段
|
||||
|
||||
```typescript
|
||||
import { USDMClient } from "binance"; // 新增引入
|
||||
|
||||
// 工具函数:提取裸 symbol(移除 .P / _PERP 后缀)
|
||||
function stripSuffix(symbol: string): string {
|
||||
return symbol.replace(/\.(P|PERP)$/, "");
|
||||
}
|
||||
|
||||
// 判断是否为合约 symbol
|
||||
function isFuturesSymbol(symbol: string): boolean {
|
||||
return symbol.endsWith(".P") || symbol.endsWith("_PERP");
|
||||
export interface Kline {
|
||||
exchange: string;
|
||||
symbol: string;
|
||||
/** 交易对类型(spot / um / cm) */
|
||||
type: PairType; // ← 新增
|
||||
interval: KlineInterval;
|
||||
openTime: number;
|
||||
closeTime: number;
|
||||
// ... 其余字段不变
|
||||
}
|
||||
```
|
||||
|
||||
现有 `fetchBinanceKlines()` 保持不变(处理现货)。新增 `fetchFuturesKlines()`:
|
||||
---
|
||||
|
||||
### 3. 实体层(1 文件)
|
||||
|
||||
**`data/db/entities/kline.entity.ts`** — `type` 从 `@PrimaryColumn` 保持不动(已存在,无需改)。
|
||||
|
||||
`trading-pair.entity.ts` — `type` 列已存在,无需改。
|
||||
|
||||
---
|
||||
|
||||
### 4. REST 客户端(2 文件,核心改动)
|
||||
|
||||
**`data/exchanges/binance/rest.ts`**
|
||||
|
||||
```typescript
|
||||
async function fetchFuturesKlines(
|
||||
symbol: string, // BTCUSDT.P
|
||||
import { MainClient, USDMClient } from "binance";
|
||||
|
||||
// convertBinanceKline 加 type 参数
|
||||
function convertBinanceKline(
|
||||
raw: BinanceRestKline,
|
||||
symbol: string,
|
||||
interval: KlineInterval,
|
||||
startTime: number,
|
||||
endTime?: number,
|
||||
limit = 500,
|
||||
): Promise<Kline[]> {
|
||||
const rawSymbol = stripSuffix(symbol); // BTCUSDT
|
||||
const client = new USDMClient({
|
||||
api_key: exchange.binanceFutures.apiKey,
|
||||
api_secret: exchange.binanceFutures.apiSecret,
|
||||
}, { timeout: 3000 });
|
||||
|
||||
const rawKlines = await client.getKlines({
|
||||
symbol: rawSymbol,
|
||||
type: PairType, // ← 新增
|
||||
): Kline {
|
||||
return {
|
||||
exchange: "binance",
|
||||
symbol,
|
||||
type, // ← 新增
|
||||
interval,
|
||||
startTime,
|
||||
endTime,
|
||||
limit: Math.min(limit, 1000),
|
||||
});
|
||||
|
||||
return rawKlines.map(k => convertBinanceKline(k, symbol, interval));
|
||||
// ... 其余字段不变
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`Client` 类 `fetchKlines()` 增加分支:
|
||||
// 现货客户端(已有,加 type = 'spot')
|
||||
export class BinanceRestClient extends BaseRestClient {
|
||||
readonly exchange = "binance";
|
||||
private client = new MainClient({...}, { timeout: 3000 });
|
||||
|
||||
```typescript
|
||||
async fetchKlines(...): Promise<Kline[]> {
|
||||
switch (this.exchange) {
|
||||
case "binance":
|
||||
if (isFuturesSymbol(symbol)) {
|
||||
return fetchFuturesKlines(symbol, interval, startTime, endTime, effectiveLimit);
|
||||
}
|
||||
return fetchBinanceKlines(symbol, interval, startTime, endTime, effectiveLimit);
|
||||
async fetchKlines(symbol, startTime, limit, endTime): Promise<Kline[]> {
|
||||
// ...
|
||||
return rawKlines.map(k => convertBinanceKline(k, symbol, "1m", "spot"));
|
||||
}
|
||||
}
|
||||
|
||||
// 合约客户端(新增)
|
||||
export class BinanceFuturesRestClient extends BaseRestClient {
|
||||
readonly exchange = "binance";
|
||||
private client = new USDMClient(
|
||||
{ api_key: exchange.binanceFutures.apiKey, api_secret: exchange.binanceFutures.apiSecret },
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
|
||||
async fetchKlines(symbol, startTime, limit, endTime): Promise<Kline[]> {
|
||||
// USDMClient.getKlines() 与 MainClient 同构 12 元组,convertBinanceKline 直接复用
|
||||
const rawKlines = await this.client.getKlines({ symbol, interval: "1m", ... });
|
||||
return rawKlines.map(k => convertBinanceKline(k, symbol, "1m", "um"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键**:`convertBinanceKline()` 传入原始 `"BTCUSDT.P"`,转换结果中 `kline.symbol` 自然就是 `"BTCUSDT.P"`,入库后与现货 `"BTCUSDT"` 不会 PK 冲突。
|
||||
**`data/exchanges/index.ts`** — 注册合约客户端
|
||||
|
||||
```typescript
|
||||
const registry: Record<string, () => BaseRestClient> = {
|
||||
binance: () => new BinanceRestClient(),
|
||||
binance_futures: () => new BinanceFuturesRestClient(), // ← 新增
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 服务层(1 文件)
|
||||
### 5. 服务层(1 文件)
|
||||
|
||||
**`data/service/kline.ts`**
|
||||
|
||||
- 不需任何改动。`upsertOrUpdateKlines()` 直接把 `Kline.symbol` 写入 `KlineEntity.symbol`,后缀天然带进去。
|
||||
```typescript
|
||||
const entities = KlineItems.map((item) => {
|
||||
const entity = new Kline();
|
||||
entity.type = item.type; // ← 新增
|
||||
// ... 其余字段映射不变
|
||||
});
|
||||
|
||||
---
|
||||
|
||||
### 4. 实体层(0 文件)
|
||||
|
||||
**结论:不需要改。**
|
||||
|
||||
- `kline.entity.ts` 的 4 列 PK 保持不变
|
||||
- `trading-pair.entity.ts` 不需要加 `account_type`,用 symbol 本身的 `.P` 后缀标识即可
|
||||
|
||||
TradingPair 的 symbol 设成 `"BTCUSDT.P"`,唯一约束 `(exchange_id, symbol)` 自动不冲突。
|
||||
|
||||
---
|
||||
|
||||
### 5. SQL 初始化脚本(1 文件)
|
||||
|
||||
**`data/db/init-db/02-init-tables.sql`**
|
||||
|
||||
种子数据新增合约交易对:
|
||||
|
||||
```sql
|
||||
INSERT INTO trading_pairs (exchange_id, symbol, base_asset, quote_asset,
|
||||
price_precision, quantity_precision, kline_interval, kline_intervals, active)
|
||||
SELECT
|
||||
e.id,
|
||||
sym.symbol,
|
||||
sym.base,
|
||||
sym.quote,
|
||||
2, 5, '1m', '1m,5m,15m,30m,1h,4h,1d,1w', TRUE
|
||||
FROM exchanges e
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
('BTCUSDT.P', 'BTC', 'USDT'),
|
||||
('ETHUSDT.P', 'ETH', 'USDT')
|
||||
) AS sym(symbol, base, quote)
|
||||
WHERE e.name = 'binance'
|
||||
ON CONFLICT (exchange_id, symbol) DO NOTHING;
|
||||
await repo.upsert(entities, {
|
||||
conflictPaths: ["exchange", "symbol", "type", "interval", "time"], // ← +type
|
||||
skipUpdateIfNoValuesChanged: true,
|
||||
});
|
||||
```
|
||||
|
||||
**klines 表结构和连续聚合视图:完全不动。** symbol 值不同,数据天然隔离。
|
||||
|
||||
---
|
||||
|
||||
### 6. 运行脚本(1 文件)
|
||||
|
||||
**`data/run/exchange.ts`**
|
||||
**`data/run/exchange.ts`** — 按 `pair.type` 选择客户端
|
||||
|
||||
- `getAllPairs()` 不受影响,返回的 `symbol` 本身就是 `"BTCUSDT.P"`
|
||||
- `new Client("binance")` 的 `fetchKlines()` 内部已按 symbol 后缀分派到 `USDMClient`
|
||||
- 回补循环逻辑不变,`lastBackfillTime` 追踪机制不变
|
||||
|
||||
**注意**:Binance 的 `USDMClient.getKlines()` 返回与 `MainClient.getKlines()` 同构的 12 元组,`convertBinanceKline()` 可以直接复用。
|
||||
```typescript
|
||||
for (const pair of allPairs) {
|
||||
const exchangeId = pair.type === 'um' ? 'binance_futures' : 'binance';
|
||||
const client = createRestClient(exchangeId);
|
||||
// ... 其余逻辑不变
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. 类型定义(0 文件)
|
||||
### 7. SQL 初始化脚本(2 文件)
|
||||
|
||||
`Kline.symbol` 字段类型已是 `string`,直接存 `"BTCUSDT.P"`。无需改动。
|
||||
**`data/db/init-db/02-init-tables.sql`**
|
||||
|
||||
klines 表:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS klines (
|
||||
exchange TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'spot', -- 新增
|
||||
interval TEXT NOT NULL,
|
||||
time TIMESTAMPTZ NOT NULL,
|
||||
-- OHLCV ...
|
||||
PRIMARY KEY (exchange, symbol, type, interval, time) -- 5 列
|
||||
);
|
||||
|
||||
ALTER TABLE klines SET (
|
||||
timescaledb.compress,
|
||||
timescaledb.compress_segmentby = 'exchange,symbol,type', -- +type
|
||||
timescaledb.compress_orderby = 'time DESC'
|
||||
);
|
||||
```
|
||||
|
||||
trading_pairs 表:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS trading_pairs (
|
||||
-- ...
|
||||
symbol VARCHAR(20) NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'spot', -- 新增
|
||||
-- ...
|
||||
CONSTRAINT uq_trading_pairs_exchange_symbol_type UNIQUE (exchange_id, symbol, type) -- +type
|
||||
);
|
||||
```
|
||||
|
||||
种子数据:
|
||||
```sql
|
||||
INSERT INTO trading_pairs (exchange_id, symbol, type, base_asset, quote_asset,
|
||||
price_precision, quantity_precision, active)
|
||||
SELECT e.id, sym.symbol, sym.type, sym.base, sym.quote, 2, 5, TRUE
|
||||
FROM exchanges e
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
('BTCUSDT', 'spot', 'BTC', 'USDT'),
|
||||
('ETHUSDT', 'spot', 'ETH', 'USDT'),
|
||||
('BNBUSDT', 'spot', 'BNB', 'USDT'),
|
||||
('SOLUSDT', 'spot', 'SOL', 'USDT'),
|
||||
('BTCUSDT', 'um', 'BTC', 'USDT'),
|
||||
('ETHUSDT', 'um', 'ETH', 'USDT')
|
||||
) AS sym(symbol, type, base, quote)
|
||||
WHERE e.name = 'binance'
|
||||
ON CONFLICT (exchange_id, symbol, type) DO NOTHING;
|
||||
```
|
||||
|
||||
**`data/db/init-db/03-continuous-aggregates.sql`**
|
||||
|
||||
12 个连续聚合视图,每个改 3 处(SELECT / GROUP BY / INDEX 各加 `type`)。以 `klines_3m` 为例:
|
||||
|
||||
```sql
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS klines_3m
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket('3 minutes', time) AS time,
|
||||
exchange,
|
||||
symbol,
|
||||
type, -- 新增
|
||||
'3m'::text AS interval,
|
||||
FIRST(open, time) AS open,
|
||||
-- ...
|
||||
FROM klines
|
||||
GROUP BY time_bucket('3 minutes', klines.time), exchange, symbol, type -- +type
|
||||
WITH NO DATA;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_klines_3m_symbol_time
|
||||
ON klines_3m (exchange, symbol, type, time DESC); -- +type
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. service/pair.ts(1 文件,小改)
|
||||
|
||||
`getPairLastBackfillTime` / `updatePairLastBackfillTime` 按 `symbol` 查找时需要限定 `type`,避免现货/合约二义性:
|
||||
|
||||
```typescript
|
||||
// 推荐:type 参数可选,默认 'spot' 向后兼容
|
||||
export async function getPairLastBackfillTime(symbol: string, type: PairType = 'spot') {
|
||||
const pair = await repo.findOneBy({ symbol, type });
|
||||
return pair?.last_backfill_time;
|
||||
}
|
||||
```
|
||||
|
||||
实际调用处(`run/exchange.ts` 中)传 `pair.type`。
|
||||
|
||||
---
|
||||
|
||||
## 改动汇总
|
||||
|
||||
| 文件 | 改动类型 | 行数估算 |
|
||||
|------|---------|---------|
|
||||
| `data/env.yaml` | 新增 `binance_futures` 段 | +4 |
|
||||
| `data/config/validators.ts` | `ExchangeConfig` + `validateConfig()` 新增解析 | +10 |
|
||||
| `data/config/index.ts` | 新增 `exchange.binanceFutures` 导出 | +5 |
|
||||
| `data/exchanges/rest.ts` | 引入 `USDMClient`,新增 `fetchFuturesKlines()`,`Client.fetchKlines()` 增加分支 | +40 |
|
||||
| `data/db/init-db/02-init-tables.sql` | seed 数据插入合约交易对 | +15 |
|
||||
| `data/run/exchange.ts` | 无实质改动(后缀自动路由) | 0 |
|
||||
| 文件 | 改动 | 行数 |
|
||||
|------|------|------|
|
||||
| `data/types/base.ts` | Kline 接口 +type | +1 |
|
||||
| `data/db/entities/kline.entity.ts` | 不动(type 已存在) | 0 |
|
||||
| `data/exchanges/binance/rest.ts` | 引入 USDMClient,拆现货/合约客户端,convertBinanceKline +type | +50 |
|
||||
| `data/exchanges/index.ts` | 注册 binance_futures | +2 |
|
||||
| `data/service/kline.ts` | entity.type 赋值,conflictPaths +1 | +3 |
|
||||
| `data/run/exchange.ts` | 按 pair.type 选择客户端 | +3 |
|
||||
| `data/service/pair.ts` | findOneBy 加 type 参数 | +5 |
|
||||
| `data/db/init-db/02-init-tables.sql` | klines +type列+PK+压缩键,trading_pairs +唯一约束+种子数据 | +10 |
|
||||
| `data/db/init-db/03-continuous-aggregates.sql` | 12 视图 × 3 处(SELECT/GROUP BY/INDEX) | +36 |
|
||||
|
||||
**不动**:klines 表结构、复合主键、连续聚合视图、service/kline.ts、types 目录、实体层。
|
||||
|
||||
共 **5-6 个文件,~70 行净改动**。
|
||||
**共 9 个文件,~110 行净改动。** 不动:配置层、env.yaml。
|
||||
|
||||
---
|
||||
|
||||
## 工作顺序
|
||||
## 迁移计划(重建数据库)
|
||||
|
||||
1. **env.yaml** — 配好 futures 的 API Key
|
||||
2. **config/validators.ts + config/index.ts** — 解析 + 导出 futures Key
|
||||
3. **exchanges/rest.ts** — 核心改动,USDMClient + 后缀分派
|
||||
4. **02-init-tables.sql** — 种子数据
|
||||
5. 验证:跑 `bun run data/run/exchange.ts` 看能否拉下 `BTCUSDT.P` 的 K 线
|
||||
6. Coin-M 同理扩展(只加 suffix 规则 + CoinMClient case)
|
||||
```
|
||||
第1步: 修改 SQL DDL
|
||||
├── 02-init-tables.sql: klines PK 加 type,trading_pairs 唯一约束加 type
|
||||
└── 03-continuous-aggregates.sql: 12 视图 SELECT/GROUP BY/INDEX 加 type
|
||||
|
||||
第2步: 修改代码
|
||||
├── types/base.ts: Kline 接口 +type
|
||||
├── exchanges/binance/rest.ts: 拆现货/合约客户端
|
||||
├── exchanges/index.ts: 注册 binance_futures
|
||||
├── service/kline.ts: entity.type + conflictPaths
|
||||
├── service/pair.ts: findOneBy 加 type
|
||||
└── run/exchange.ts: 按 pair.type 选客户端
|
||||
|
||||
第3步: 重建数据库
|
||||
├── docker compose down -v && docker compose up -d (清空数据)
|
||||
├── 执行 01 → 02 → 03 SQL 初始化脚本
|
||||
└── bun run data/run/exchange.ts 全量回补
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `binance_futures` 的 API Key 需要在 Binance 开通合约交易权限,否则 `USDMClient` 调用会报权限错误。
|
||||
- 合约 K 线数量通常多于现货(7×24 交易),回补时间更长。
|
||||
- Coin-M 接入只需:`PairType` 已有 `'cm'`,新增 `CoinMClient` 类,种子数据加 `('BTCUSDT', 'cm', ...)` 即可。
|
||||
|
||||
Reference in New Issue
Block a user