diff --git a/AGENTS/08-boundaries.md b/AGENTS/08-boundaries.md new file mode 100644 index 0000000..43b7c4e --- /dev/null +++ b/AGENTS/08-boundaries.md @@ -0,0 +1,8 @@ +# 边界与执行纪律 + +- **严格执行指定范围**:用户说"执行第一步",就只做第一步,不准提前做第二步。哪怕第一步做完后系统自动推进了任务列表,也要停下来等用户明确指示下一步。 +- **先确认,再动手**:改动涉及多个文件或架构决策时,先以方案/计划形式呈现,等用户点头再实施。不默认用户同意。 +- **不做未要求的改动**:不顺手修 bug、不重构、不加功能,除非用户明确提出。哪怕看起来是"顺便"的小改动,也可能干扰用户意图。 +- **read_file 不属于"动手写"**:读文件没问题。改一个文件前先读清楚当前内容。 +- **一次改一处**:宁可用多个单步 edit_file 提交,也不用 multi_edit 批量改整个文件。改完一个文件让用户看到,再继续下一个。 +- **拒绝时停下来问**:用户拒绝一个操作(declined),不要立即换一种方式重试。先搞清为什么被拒绝。 diff --git a/PLAN-add-futures-data.md b/PLAN-add-futures-data.md new file mode 100644 index 0000000..f4f9dc4 --- /dev/null +++ b/PLAN-add-futures-data.md @@ -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 { + 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 { + 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) diff --git a/data/config/index.ts b/data/config/index.ts index d371f7d..b29d36f 100644 --- a/data/config/index.ts +++ b/data/config/index.ts @@ -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, diff --git a/data/config/validators.ts b/data/config/validators.ts index 3fefe06..1c6ff2e 100644 --- a/data/config/validators.ts +++ b/data/config/validators.ts @@ -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; + 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, diff --git a/data/db/entities/trading-pair.entity.ts b/data/db/entities/trading-pair.entity.ts index 20f5e16..14b3444 100644 --- a/data/db/entities/trading-pair.entity.ts +++ b/data/db/entities/trading-pair.entity.ts @@ -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); - } } diff --git a/data/db/init-db/02-init-tables.sql b/data/db/init-db/02-init-tables.sql index 5211d02..efd3f46 100644 --- a/data/db/init-db/02-init-tables.sql +++ b/data/db/init-db/02-init-tables.sql @@ -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_precision(USDT 计价通常 2 位小数) 5, -- quantity_precision(数量通常 5 位小数) - '1m', - '1m,5m,15m,30m,1h,4h,1d,1w', TRUE FROM exchanges e CROSS JOIN ( diff --git a/data/example/pair.ts b/data/example/pair.ts index 1606f94..2ee289c 100644 --- a/data/example/pair.ts +++ b/data/example/pair.ts @@ -58,7 +58,6 @@ async function run(): Promise { base_asset: seed.baseAsset, quote_asset: seed.quoteAsset, active: true, - kline_synthesis_enabled: true, }); await pairRepo.save(pair); diff --git a/data/exchanges/base_rest.ts b/data/exchanges/base_rest.ts index 02ae80f..1f007d9 100644 --- a/data/exchanges/base_rest.ts +++ b/data/exchanges/base_rest.ts @@ -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, diff --git a/data/exchanges/rest.ts b/data/exchanges/rest.ts index dff552a..e709dba 100644 --- a/data/exchanges/rest.ts +++ b/data/exchanges/rest.ts @@ -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 = { 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); diff --git a/data/run/exchange.ts b/data/run/exchange.ts index 43abe1c..2af76e2 100644 --- a/data/run/exchange.ts +++ b/data/run/exchange.ts @@ -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 ); diff --git a/data/service/bnkline.ts b/data/service/bnkline.ts index f9713e1..a51dcde 100644 --- a/data/service/bnkline.ts +++ b/data/service/bnkline.ts @@ -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 { - // 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)); \ No newline at end of file diff --git a/data/types/base.ts b/data/types/base.ts index 1c341a1..bc0fbca 100644 --- a/data/types/base.ts +++ b/data/types/base.ts @@ -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, diff --git a/env.yaml b/env.yaml index 3e8e3f3..c5dbd03 100644 --- a/env.yaml +++ b/env.yaml @@ -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: