refactor(data): 重构交易所适配器,修复 Kline 实体复合主键
- 废弃旧 adapter 体系 (base/binance/types.ts),新增 base_rest/rest.ts 基于 Binance 官方 SDK 实现 REST K 线拉取 - Kline 实体改为四列复合主键 (exchange/symbol/interval/time), 修复单列 time PK 导致的跨 symbol 写入冲突 - 新增 filterConsecutive():K 线连续性过滤,首缺口截断策略 - 新增 service/kline.ts:批量 UPSERT K 线入库 - 新增 types/ 共享类型定义、example/ 示例、run/ 运行脚本
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
import { MainClient, type Kline as BinanceRestKline } from "binance";
|
||||
|
||||
import { logger } from "../utils/logger";
|
||||
import { BaseRestClient } from './base_rest';
|
||||
import type { KlineInterval, Kline, MarketInfo } from '../types';
|
||||
|
||||
/** K 线周期 → 毫秒数映射(用于时间桶计算) */
|
||||
export const KLINE_INTERVAL_MS: Record<KlineInterval, number> = {
|
||||
"1m": 60_000,
|
||||
"5m": 300_000,
|
||||
"15m": 900_000,
|
||||
"30m": 1_800_000,
|
||||
"1h": 3_600_000,
|
||||
"4h": 14_400_000,
|
||||
"1d": 86_400_000,
|
||||
"1w": 604_800_000,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Binance REST K 线 → 本系统标准化 Kline 转换
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Binance SDK Kline 元组格式(getKlines / getUIKlines 返回):
|
||||
* [0] openTime: number — 开盘时间(Unix ms)
|
||||
* [1] open: numberInString — 开盘价
|
||||
* [2] high: numberInString — 最高价
|
||||
* [3] low: numberInString — 最低价
|
||||
* [4] close: numberInString — 收盘价
|
||||
* [5] volume: numberInString — 成交量(base 币种)
|
||||
* [6] closeTime: number — 收盘时间(Unix ms)
|
||||
* [7] quoteVolume: numberInString — 成交额(quote 币种)
|
||||
* [8] tradeCount: number — 成交笔数
|
||||
* [9] takerBuyBaseVol: numberInString — 主动买入成交量
|
||||
* [10] takerBuyQuoteVol: numberInString — 主动买入成交额
|
||||
* [11] ignore: numberInString — 忽略字段
|
||||
*
|
||||
* numberInString = string | number,通过 Number() 统一转换。
|
||||
*
|
||||
* 参考:node_modules/binance/lib/types/shared.d.ts:85-98
|
||||
*/
|
||||
function convertBinanceKline(
|
||||
raw: BinanceRestKline,
|
||||
symbol: string,
|
||||
interval: KlineInterval,
|
||||
): Kline {
|
||||
const [
|
||||
openTime,
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume,
|
||||
closeTime,
|
||||
quoteVolume,
|
||||
tradeCount,
|
||||
takerBuyBaseVol,
|
||||
takerBuyQuoteVol,
|
||||
// [11] ignore — 丢弃
|
||||
] = raw;
|
||||
|
||||
return {
|
||||
exchange: "binance",
|
||||
symbol,
|
||||
interval,
|
||||
openTime: openTime,
|
||||
closeTime: closeTime,
|
||||
open: String(open),
|
||||
high: String(high),
|
||||
low: String(low),
|
||||
close: String(close),
|
||||
volume: String(volume),
|
||||
quoteVolume: String(quoteVolume),
|
||||
takerBuyBaseVol: String(takerBuyBaseVol),
|
||||
takerBuyQuoteVol: String(takerBuyQuoteVol),
|
||||
tradeCount: String(tradeCount),
|
||||
isClosed: true, // REST 返回的 K 线均为已闭合历史数据
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Binance REST 拉取函数
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 通过 Binance 原生 SDK 拉取 UI 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,
|
||||
): Promise<Kline[]> {
|
||||
const client = new MainClient({
|
||||
api_key: 'ONSJKIGRpDYLn6FdV17aAKfjclZ4I2LzamflhuMpsoRQA427lLKeyJlGtg2RZ7DH',
|
||||
api_secret: '5Mfv4TgvDlRzCHbtl2nJL4mVHUvMm8pyjKiRjMoosBMxrhlqMw6CuQbg2qbS2Npd',
|
||||
});
|
||||
|
||||
// Binance 硬限制:单次最多 1000 条
|
||||
const safeLimit = Math.min(limit, 1000);
|
||||
|
||||
const rawKlines = await client.getKlines({
|
||||
symbol,
|
||||
interval,
|
||||
startTime,
|
||||
endTime,
|
||||
limit: safeLimit,
|
||||
});
|
||||
|
||||
logger.info({
|
||||
symbol,
|
||||
interval,
|
||||
startTime,
|
||||
endTime,
|
||||
limit: safeLimit,
|
||||
}, 'fetchBinanceKlines arguments');
|
||||
|
||||
if (!rawKlines || rawKlines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return filterConsecutive(
|
||||
rawKlines.map((k, index) => {
|
||||
// if (index === rawKlines.length - 1) {
|
||||
// console.log(k);
|
||||
// }
|
||||
return convertBinanceKline(k, symbol, interval);
|
||||
}),
|
||||
interval,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤出严格连续(无时间缺口)的 K 线序列。
|
||||
*
|
||||
* 处理流程:
|
||||
* 1. 按 openTime 升序排序(防御性,确保时间单调递增)
|
||||
* 2. 从首条 K 线开始遍历,仅保留相邻间隔恰好等于 intervalMs 的条目
|
||||
* 3. 一旦检测到缺口(间隔 ≠ intervalMs),立即终止并丢弃后续所有数据
|
||||
*
|
||||
* 设计意图:
|
||||
* - 时间序列分析(回测、指标计算)依赖连续数据,缺口会引入偏误
|
||||
* - 缺口之后的数据可能来自另一段不连续的拉取结果,混入后风险更高
|
||||
* - "截断"策略优于"填充/跳过",避免伪造数据或隐藏数据质量问题
|
||||
*
|
||||
* @param klines - 待过滤的 K 线数组(可能乱序、可能含缺口)
|
||||
* @param interval - K 线周期,用于查表获取 intervalMs
|
||||
* @returns 从首条开始严格连续的最大前缀子序列;空数组无缺口时返回完整排序结果
|
||||
*/
|
||||
function filterConsecutive(klines: Kline[], interval: KlineInterval) {
|
||||
// 查表获取当前 K 线周期对应的毫秒数
|
||||
const intervalMs = KLINE_INTERVAL_MS[interval];
|
||||
|
||||
// 防御性排序:Binance API 不保证返回顺序,升序排列确保时间单调
|
||||
const results = klines.sort((a: Kline, b: Kline) => {
|
||||
return a.openTime - b.openTime;
|
||||
});
|
||||
|
||||
return results;
|
||||
|
||||
// console.log(results);
|
||||
|
||||
// let _openTime = 0; // 哨兵:0 表示尚未初始化,非 0 表示上一条已收录 K 线的 openTime
|
||||
// const rets: Kline[] = []; // 累积连续 K 线结果
|
||||
|
||||
// for (let item of results) {
|
||||
|
||||
// console.log(item.openTime);
|
||||
// // 分支 1 —— 首条 K 线:无条件收录,并初始化哨兵
|
||||
// if (_openTime === 0) {
|
||||
// _openTime = item.openTime;
|
||||
// rets.push(item);
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// // 分支 2 —— 严格连续:当前 openTime 与上一条恰好相差一个周期
|
||||
// if (item.openTime - _openTime === intervalMs) {
|
||||
// _openTime = item.openTime;
|
||||
// rets.push(item);
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// // 分支 3 —— 检测到缺口:截断,丢弃当前及之后所有 K 线
|
||||
// break;
|
||||
// }
|
||||
|
||||
// return rets;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Client —— 多交易所 REST 客户端
|
||||
// ============================================================
|
||||
|
||||
export class Client extends BaseRestClient {
|
||||
exchange: string;
|
||||
|
||||
/**
|
||||
* @param exchange - 交易所 ID(如 "binance"、"okx"、"bybit")
|
||||
* 内部根据 ID 分发到对应的 SDK 实现
|
||||
*/
|
||||
constructor(exchange: string) {
|
||||
super();
|
||||
this.exchange = exchange;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取历史 K 线数据,返回标准化 Kline 数组。
|
||||
*
|
||||
* 根据交易所 ID 分发到各自的 SDK 拉取函数。
|
||||
*
|
||||
* @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,
|
||||
): Promise<Kline[]> {
|
||||
const effectiveLimit = limit ?? this.config.defaultLimit;
|
||||
switch (this.exchange) {
|
||||
case "binance":
|
||||
return fetchBinanceKlines(symbol, interval, startTime, endTime, effectiveLimit);
|
||||
// TODO: 新增交易所在此添加 case
|
||||
// case "okx":
|
||||
// return fetchOkxKlines(symbol, interval, startTime, endTime, effectiveLimit);
|
||||
default:
|
||||
throw new Error(
|
||||
`[Client] 不支持的交易所: "${this.exchange}",` +
|
||||
`当前仅支持: binance`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMarkets(): Promise<MarketInfo[]> {
|
||||
// TODO: 各交易所实现
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user