Files
trade/PLAN-add-futures-data.md
T
Rekey 705a2f6ea0 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 空函数
2026-06-16 18:39:40 +08:00

300 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 接入 USDT-M 合约数据 — 改造方案(type 字段方案)
## 设计思路
**用 `type` 列区分账户类型**,而非 symbol 后缀。`PairType` 枚举已在代码中定义(`'spot' | 'um' | 'cm'`),本次改造将 `type` 从实体层的死代码变成全链路透传的一等字段。
### 为什么选 type 而非 .P 后缀
| | `.P` 后缀 | `type` 字段 |
|---|---|---|
| symbol 语义 | 污染标识符,BTCUSDT.P 不是交易对名称 | 干净,symbol 与 Binance 官方一致 |
| 查询 | `WHERE symbol LIKE '%.P'` 不走索引 | `WHERE type = 'um'` 索引友好 |
| 扩展 | Coin-M 需要 `_PERP` 新后缀规则 | 加 `'cm'` 枚举值即可 |
| API 调用 | 每次 strip 后缀,容易遗漏 | symbol 原样传入,零变换 |
| 与已有代码 | 与 TradingPair.type 字段矛盾 | 与已有 PairType、实体定义完全对齐 |
### Type 枚举
| type | 说明 |
|------|------|
| `spot` | 现货(默认值) |
| `um` | USDT-M 永续合约 |
| `cm` | Coin-M 永续合约(预留) |
### K 线复合主键
5 列:`(exchange, symbol, type, interval, time)``type` 在主键中保证相同 symbol 的现货与合约 K 线不会 PK 冲突。
---
## 改动清单
### 1. 配置层(已完成,无需再改)
`data/env.yaml``data/config/validators.ts``data/config/index.ts` 已有 `binance_futures` 段,无需改动。
---
### 2. 类型定义(1 文件)
**`data/types/base.ts`** — Kline 接口加 `type` 字段
```typescript
export interface Kline {
exchange: string;
symbol: string;
/** 交易对类型(spot / um / cm */
type: PairType; // ← 新增
interval: KlineInterval;
openTime: number;
closeTime: number;
// ... 其余字段不变
}
```
---
### 3. 实体层(1 文件)
**`data/db/entities/kline.entity.ts`** — `type``@PrimaryColumn` 保持不动(已存在,无需改)。
`trading-pair.entity.ts``type` 列已存在,无需改。
---
### 4. REST 客户端(2 文件,核心改动)
**`data/exchanges/binance/rest.ts`**
```typescript
import { MainClient, USDMClient } from "binance";
// convertBinanceKline 加 type 参数
function convertBinanceKline(
raw: BinanceRestKline,
symbol: string,
interval: KlineInterval,
type: PairType, // ← 新增
): Kline {
return {
exchange: "binance",
symbol,
type, // ← 新增
interval,
// ... 其余字段不变
};
}
// 现货客户端(已有,加 type = 'spot'
export class BinanceRestClient extends BaseRestClient {
readonly exchange = "binance";
private client = new MainClient({...}, { timeout: 3000 });
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"));
}
}
```
**`data/exchanges/index.ts`** — 注册合约客户端
```typescript
const registry: Record<string, () => BaseRestClient> = {
binance: () => new BinanceRestClient(),
binance_futures: () => new BinanceFuturesRestClient(), // ← 新增
};
```
---
### 5. 服务层(1 文件)
**`data/service/kline.ts`**
```typescript
const entities = KlineItems.map((item) => {
const entity = new Kline();
entity.type = item.type; // ← 新增
// ... 其余字段映射不变
});
await repo.upsert(entities, {
conflictPaths: ["exchange", "symbol", "type", "interval", "time"], // ← +type
skipUpdateIfNoValuesChanged: true,
});
```
---
### 6. 运行脚本(1 文件)
**`data/run/exchange.ts`** — 按 `pair.type` 选择客户端
```typescript
for (const pair of allPairs) {
const exchangeId = pair.type === 'um' ? 'binance_futures' : 'binance';
const client = createRestClient(exchangeId);
// ... 其余逻辑不变
}
```
---
### 7. SQL 初始化脚本(2 文件)
**`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.ts1 文件,小改)
`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/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 |
**共 9 个文件,~110 行净改动。** 不动:配置层、env.yaml。
---
## 迁移计划(重建数据库)
```
第1步: 修改 SQL DDL
├── 02-init-tables.sql: klines PK 加 typetrading_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', ...)` 即可。