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