# 接入 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 { // ... 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 { // 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 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.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/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 加 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', ...)` 即可。