添加 USDT-M 合约数据支持(配置层 + 清理多余字段)

- 配置层:env.yaml 新增 binance_futures API Key 段,validators + config 同步
- 清理 TradingPair 实体:删除 kline_interval、kline_intervals、kline_synthesis_enabled
- 删除 fetchKlines 系列函数的 interval 参数,硬编码为 1m
- 更新 SQL seed 数据、example、base_rest 接口、types 接口
- 新增 AGENTS/08-boundaries.md 执行纪律
- 新增 PLAN-add-futures-data.md 方案文档
This commit is contained in:
Rekey
2026-06-15 23:24:21 +08:00
parent 6708abaf56
commit b4c7636731
13 changed files with 255 additions and 78 deletions
+8
View File
@@ -0,0 +1,8 @@
# 边界与执行纪律
- **严格执行指定范围**:用户说"执行第一步",就只做第一步,不准提前做第二步。哪怕第一步做完后系统自动推进了任务列表,也要停下来等用户明确指示下一步。
- **先确认,再动手**:改动涉及多个文件或架构决策时,先以方案/计划形式呈现,等用户点头再实施。不默认用户同意。
- **不做未要求的改动**:不顺手修 bug、不重构、不加功能,除非用户明确提出。哪怕看起来是"顺便"的小改动,也可能干扰用户意图。
- **read_file 不属于"动手写"**:读文件没问题。改一个文件前先读清楚当前内容。
- **一次改一处**:宁可用多个单步 edit_file 提交,也不用 multi_edit 批量改整个文件。改完一个文件让用户看到,再继续下一个。
- **拒绝时停下来问**:用户拒绝一个操作(declined),不要立即换一种方式重试。先搞清为什么被拒绝。
+198
View File
@@ -0,0 +1,198 @@
# 接入 USDT-M 合约数据 — 改造方案
## 设计思路
**用 symbol 后缀区分账户类型**,而非新增 `account_type` 列。核心原则是尽量不动已有表结构和聚合视图。
### Symbol 命名约定
| 账户类型 | symbol 示例 | 说明 |
|---------|-------------|------|
| 现货 | `BTCUSDT` | 不变 |
| USDT-M 永续 | `BTCUSDT.P` | `.P` 后缀标记合约 |
| Coin-M 永续 | `BTCUSDT_PERP` | 预留 |
**核心机制**:对外(入库、查询、展示)统一用带后缀的 symbol;对内(调用 Binance SDK)自动 strip 后缀。
---
## 改动清单
### 1. 配置层(3 文件)
**`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`
---
### 2. REST 客户端(1 文件,核心改动)
**`data/exchanges/rest.ts`**
```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");
}
```
现有 `fetchBinanceKlines()` 保持不变(处理现货)。新增 `fetchFuturesKlines()`
```typescript
async function fetchFuturesKlines(
symbol: string, // BTCUSDT.P
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,
interval,
startTime,
endTime,
limit: Math.min(limit, 1000),
});
return rawKlines.map(k => convertBinanceKline(k, symbol, interval));
}
```
`Client``fetchKlines()` 增加分支:
```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);
}
}
```
**关键**`convertBinanceKline()` 传入原始 `"BTCUSDT.P"`,转换结果中 `kline.symbol` 自然就是 `"BTCUSDT.P"`,入库后与现货 `"BTCUSDT"` 不会 PK 冲突。
---
### 3. 服务层(1 文件)
**`data/service/kline.ts`**
- 不需任何改动。`upsertOrUpdateKlines()` 直接把 `Kline.symbol` 写入 `KlineEntity.symbol`,后缀天然带进去。
---
### 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;
```
**klines 表结构和连续聚合视图:完全不动。** symbol 值不同,数据天然隔离。
---
### 6. 运行脚本(1 文件)
**`data/run/exchange.ts`**
- `getAllPairs()` 不受影响,返回的 `symbol` 本身就是 `"BTCUSDT.P"`
- `new Client("binance")``fetchKlines()` 内部已按 symbol 后缀分派到 `USDMClient`
- 回补循环逻辑不变,`lastBackfillTime` 追踪机制不变
**注意**Binance 的 `USDMClient.getKlines()` 返回与 `MainClient.getKlines()` 同构的 12 元组,`convertBinanceKline()` 可以直接复用。
---
### 7. 类型定义(0 文件)
`Kline.symbol` 字段类型已是 `string`,直接存 `"BTCUSDT.P"`。无需改动。
---
## 改动汇总
| 文件 | 改动类型 | 行数估算 |
|------|---------|---------|
| `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 |
**不动**:klines 表结构、复合主键、连续聚合视图、service/kline.ts、types 目录、实体层。
**5-6 个文件,~70 行净改动**
---
## 工作顺序
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
+9
View File
@@ -144,6 +144,11 @@ export const exchange = {
apiKey: rawConfig.exchange.binance.api_key,
apiSecret: rawConfig.exchange.binance.api_secret,
},
/** USDT-M 永续合约 API Key */
binanceFutures: {
apiKey: rawConfig.exchange.binance_futures.api_key,
apiSecret: rawConfig.exchange.binance_futures.api_secret,
},
} as const;
// ============================================================
@@ -170,6 +175,10 @@ export function printConfigSummary(): void {
apiKey: exchange.binance.apiKey.slice(0, 6) + "***",
apiSecret: "***",
},
binanceFutures: {
apiKey: exchange.binanceFutures.apiKey.slice(0, 6) + "***",
apiSecret: "***",
},
},
logging: {
level: logging.level,
+16
View File
@@ -34,7 +34,10 @@ export interface RedisConfig {
/** 交易所 API 密钥配置(按交易所 ID 索引) */
export interface ExchangeConfig {
/** 现货市场 API Key */
binance: ExchangeApiKeys;
/** USDT-M 永续合约 API Key */
binance_futures: ExchangeApiKeys;
// 未来扩展:okx、bybit 等
[exchangeId: string]: ExchangeApiKeys | undefined;
}
@@ -105,6 +108,15 @@ export function validateConfig(raw: unknown): EnvConfig {
const binanceApiKey = assertString(binanceObj["api_key"], "exchange.binance.api_key");
const binanceApiSecret = assertString(binanceObj["api_secret"], "exchange.binance.api_secret");
// --- binance_futures ---
const binanceFutures = exObj["binance_futures"];
if (typeof binanceFutures !== "object" || binanceFutures === null) {
throw new Error("[config] env.yaml exchange 缺少 binance_futures 配置");
}
const futuresObj = binanceFutures as Record<string, unknown>;
const futuresApiKey = assertString(futuresObj["api_key"], "exchange.binance_futures.api_key");
const futuresApiSecret = assertString(futuresObj["api_secret"], "exchange.binance_futures.api_secret");
// --- logging ---
const logging = obj["logging"];
if (typeof logging !== "object" || logging === null) {
@@ -132,6 +144,10 @@ export function validateConfig(raw: unknown): EnvConfig {
api_key: binanceApiKey,
api_secret: binanceApiSecret,
},
binance_futures: {
api_key: futuresApiKey,
api_secret: futuresApiSecret,
},
},
logging: {
level: logLevel,
-26
View File
@@ -22,8 +22,6 @@ import {
import { Exchange } from "./exchange.entity";
import { CommonBaseEntity } from "./common.entity";
import type { KlineInterval } from '../../types';
@Entity("trading_pairs")
@Index(["exchange", "symbol"], { unique: true }) // 同一交易所下 symbol 唯一
@Index(["active"]) // 按激活状态快速筛选
@@ -69,18 +67,6 @@ export class TradingPair extends CommonBaseEntity {
@Column("boolean", { default: true })
active!: boolean;
/** 是否启用 K 线合成(false 时仅采集原始行情,不合成) */
@Column("boolean", { default: true })
kline_synthesis_enabled!: boolean;
/** K 线时间周期 */
@Column("varchar", { length: 100, default: "1m" })
kline_interval!: KlineInterval;
/** 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 线的结束时间戳,
@@ -96,16 +82,4 @@ export class TradingPair extends CommonBaseEntity {
/** 备注 */
@Column("text", { nullable: true })
notes?: string;
// ============================================================
// 工具方法
// ============================================================
/** 解析 kline_intervals 为周期数组 */
getIntervals(): string[] {
return this.kline_intervals
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
}
+1 -12
View File
@@ -93,15 +93,6 @@ CREATE TABLE IF NOT EXISTS trading_pairs (
-- 是否激活数据订阅(false 时不采集该交易对行情)
active BOOLEAN NOT NULL DEFAULT TRUE,
-- 是否启用 K 线合成(false 时仅采集原始行情,不合成)
kline_synthesis_enabled BOOLEAN NOT NULL DEFAULT TRUE,
-- 默认 K 线周期
kline_interval VARCHAR(100) NOT NULL DEFAULT '1m',
-- K 线合成周期列表(逗号分隔,如 "1m,5m,15m,1h,4h,1d"
kline_intervals VARCHAR(100) NOT NULL DEFAULT '1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,1d,1w,1mon',
-- 历史 K 线最后补全时间(UTC)。默认 Unix epoch 起始,
-- 新交易对从 epoch 起始时间开始全量补拉。
last_backfill_time TIMESTAMPTZ NOT NULL DEFAULT to_timestamp(0),
@@ -321,7 +312,7 @@ ON CONFLICT (name) DO NOTHING;
-- 默认交易对(仅 Binance 主流 USDT 永续合约,幂等)
INSERT INTO trading_pairs (exchange_id, symbol, base_asset, quote_asset,
price_precision, quantity_precision, kline_interval, kline_intervals, active)
price_precision, quantity_precision, active)
SELECT
e.id,
sym.symbol,
@@ -329,8 +320,6 @@ SELECT
sym.quote,
2, -- price_precisionUSDT 计价通常 2 位小数)
5, -- quantity_precision(数量通常 5 位小数)
'1m',
'1m,5m,15m,30m,1h,4h,1d,1w',
TRUE
FROM exchanges e
CROSS JOIN (
-1
View File
@@ -58,7 +58,6 @@ async function run(): Promise<void> {
base_asset: seed.baseAsset,
quote_asset: seed.quoteAsset,
active: true,
kline_synthesis_enabled: true,
});
await pairRepo.save(pair);
+2 -4
View File
@@ -13,7 +13,7 @@
// ============================================================
import { logger } from "../utils/logger";
import type { Kline, KlineInterval, MarketInfo, RestClientConfig } from "../types";
import type { Kline, MarketInfo, RestClientConfig } from "../types";
import { DEFAULT_REST_CONFIG } from "../types/base";
// ============================================================
@@ -76,7 +76,7 @@ export abstract class BaseRestClient {
// ============================================================
/**
* 拉取历史 K 线数据(REST)。
* 拉取 1m 历史 K 线数据(REST)。
*
* 子类负责:
* 1. 调用交易所原生 SDK 的 K 线接口
@@ -84,14 +84,12 @@ export abstract class BaseRestClient {
* 3. 处理分页逻辑(若时间跨度超过单次请求上限)
*
* @param symbol - 交易对符号(如 BTCUSDT
* @param interval - K 线周期
* @param startTime - 起始时间(Unix ms
* @param endTime - 结束时间(Unix ms
* @param limit - 单次最大条数(默认取自 config.defaultLimit
*/
abstract fetchKlines(
symbol: string,
interval: KlineInterval,
startTime: number,
endTime: number,
limit?: number,
+11 -24
View File
@@ -1,4 +1,4 @@
import { MainClient, type Kline as BinanceRestKline } from "binance";
import { MainClient, type Kline as BinanceRestKline, CoinMClient } from "binance";
import { logger } from "../utils/logger";
import { exchange } from "../config";
@@ -48,7 +48,6 @@ export const KLINE_INTERVAL_MS: Record<KlineInterval, number> = {
function convertBinanceKline(
raw: BinanceRestKline,
symbol: string,
interval: KlineInterval,
): Kline {
const [
openTime,
@@ -68,7 +67,7 @@ function convertBinanceKline(
return {
exchange: "binance",
symbol,
interval,
interval: "1m",
openTime: openTime,
closeTime: closeTime,
open: String(open),
@@ -89,20 +88,18 @@ function convertBinanceKline(
// ============================================================
/**
* 通过 Binance 原生 SDK 拉取 UI K 线并转换为本系统 Kline。
* 通过 Binance 原生 SDK 拉取 1m K 线并转换为本系统 Kline。
*
* getUIKlines 与 getKlines 返回同构的 Kline[] 元组,
* getUIKlines 额外支持 timeZone 参数,适合按交易所时区对齐。
*
* @param symbol - 交易对(如 BTCUSDT
* @param interval - K 线周期
* @param startTime - 起始时间(Unix ms
* @param endTime - 结束时间(Unix ms),可选
* @param limit - 单次拉取条数,默认 500(最大 1000)
*/
async function fetchBinanceKlines(
symbol: string,
interval: KlineInterval,
startTime: number,
endTime?: number,
limit = 500,
@@ -119,15 +116,15 @@ async function fetchBinanceKlines(
const rawKlines = await client.getKlines({
symbol,
interval,
interval: "1m",
startTime,
endTime,
limit: safeLimit,
});
logger.info({
logger.debug({
symbol,
interval,
interval: "1m",
startTime,
endTime,
limit: safeLimit,
@@ -138,13 +135,7 @@ async function fetchBinanceKlines(
}
return filterConsecutive(
rawKlines.map((k, index) => {
// if (index === rawKlines.length - 1) {
// console.log(k);
// }
return convertBinanceKline(k, symbol, interval);
}),
interval,
rawKlines.map((k) => convertBinanceKline(k, symbol)),
);
}
@@ -165,10 +156,7 @@ async function fetchBinanceKlines(
* @param interval - K 线周期,用于查表获取 intervalMs
* @returns 从首条开始严格连续的最大前缀子序列;空数组无缺口时返回完整排序结果
*/
function filterConsecutive(klines: Kline[], interval: KlineInterval) {
// 查表获取当前 K 线周期对应的毫秒数
const intervalMs = KLINE_INTERVAL_MS[interval];
function filterConsecutive(klines: Kline[]) {
// 防御性排序:Binance API 不保证返回顺序,升序排列确保时间单调
const results = klines.sort((a: Kline, b: Kline) => {
return a.openTime - b.openTime;
@@ -222,19 +210,18 @@ export class Client extends BaseRestClient {
}
/**
* 拉取历史 K 线数据,返回标准化 Kline 数组。
* 拉取 1m 历史 K 线数据,返回标准化 Kline 数组。
*
* 根据交易所 ID 分发到各自的 SDK 拉取函数。
* K 线周期固定为 1m,高周期通过 TimescaleDB 连续聚合视图生成。
*
* @param symbol - 交易对符号(如 BTCUSDT
* @param interval - K 线周期
* @param startTime - 起始时间(Unix ms
* @param endTime - 结束时间(Unix ms),可选
* @param limit - 最大返回条数,默认取自 config.defaultLimit
*/
async fetchKlines(
symbol: string,
interval: KlineInterval,
startTime: number,
limit?: number,
endTime?: number,
@@ -242,7 +229,7 @@ export class Client extends BaseRestClient {
const effectiveLimit = limit ?? this.config.defaultLimit;
switch (this.exchange) {
case "binance":
return fetchBinanceKlines(symbol, interval, startTime, endTime, effectiveLimit);
return fetchBinanceKlines(symbol, startTime, endTime, effectiveLimit);
// TODO: 新增交易所在此添加 case
// case "okx":
// return fetchOkxKlines(symbol, interval, startTime, endTime, effectiveLimit);
-1
View File
@@ -18,7 +18,6 @@ for (const pair of allPairs) {
console.log('lastBackfillTime', lastBackfillTime);
const klines = await client.fetchKlines(
pair.symbol,
pair.kline_interval,
lastBackfillTime,
500
);
+6 -8
View File
@@ -1,28 +1,26 @@
import { Client } from "../exchanges/rest";
import type { Kline, KlineInterval } from "../types";
import type { Kline } from "../types";
const client = new Client("binance");
/**
* 获取 Binance K 线数据(基于 MainClient REST API)。
* 获取 Binance 1m K 线数据(基于 MainClient REST API)。
*
* 内部复用 Client(多交易所 REST 客户端)的 binance 实现,
* 包含限流、Binance SDK 原生转换、连续性过滤等逻辑。
* 返回本系统标准化 {@link Kline} 数组。
*
* @param symbol - 交易对符号(如 "BTCUSDT"
* @param interval - K 线周期(如 "1h"、"4h"、"1d"
* @param startTime - 起始时间(Unix ms
* @param endTime - 结束时间(Unix ms),可选;不传则拉取到最新
* @param limit - 单次拉取条数,默认 500(最大 1000)
*/
export async function fetchKlines(
symbol: string,
interval: KlineInterval,
startTime: number,
endTime?: number,
limit = 500,
): Promise<Kline[]> {
// Client.fetchKlines 参数顺序:symbol, interval, startTime, limit, endTime
return client.fetchKlines(symbol, interval, startTime, limit, endTime);
return client.fetchKlines(symbol, startTime, limit);
}
console.log(await fetchKlines('BTCUSDT.P', 0, 10));
-2
View File
@@ -234,7 +234,6 @@ export interface MarketDataFeed {
* REST 拉取历史 K 线(用于补齐缺失数据或回测)。
*
* @param symbol - 交易对符号
* @param interval - K 线周期
* @param startTime - 起始时间(Unix ms
* @param endTime - 结束时间(Unix ms
* @param limit - 最大返回条数(默认 500)
@@ -242,7 +241,6 @@ export interface MarketDataFeed {
*/
fetchKlines(
symbol: string,
interval: KlineInterval,
startTime: number,
endTime: number,
limit?: number,
+4
View File
@@ -27,6 +27,10 @@ exchange:
binance:
api_key: "ONSJKIGRpDYLn6FdV17aAKfjclZ4I2LzamflhuMpsoRQA427lLKeyJlGtg2RZ7DH"
api_secret: "5Mfv4TgvDlRzCHbtl2nJL4mVHUvMm8pyjKiRjMoosBMxrhlqMw6CuQbg2qbS2Npd"
# USDT-M 永续合约(需要单独开通合约交易权限)
binance_futures:
api_key: "YOUR_FUTURES_API_KEY"
api_secret: "YOUR_FUTURES_API_SECRET"
# --- 日志 ---
logging: