Files
trade/data/exchanges/binance.ts
T
Rekey 85a0031a78 feat(data): 实现 Binance WebSocket 适配器与架构重构
- 新增 exchanges/ 模块:MarketDataFeed 统一接口、BaseExchangeAdapter 抽象基类、
  BinanceAdapter 完整实现(WebSocket + REST)
- WebSocket 层基于 binance 官方 SDK 的 WebsocketClient,自动多路复用与断线重连
- REST 层使用 MainClient(Spot),实现 fetchKlines 自动分页补拉 + fetchMarkets 元数据解析
- 数据标准化:Ticker/Trade/OrderBook/Kline 类型定义与 Binance 原生格式互转
- 引入 RxJS Subject 作为统一事件流管道,按 eventType 运行时路由分发
- 重构 config/:YAML 驱动配置加载 + 零依赖运行时校验(fail-fast)
- 重构 db/:TypeORM DataSource 配置 + TimescaleDB K 线 Hypertable 实体
- 新增 utils/logger.ts:Pino 结构化日志(开发环境 pino-pretty 彩色输出)
- 新增 env.yaml 作为 TS/Python 共享的统一环境配置源
- 删除旧版手写 SQL schema 与散落配置文件,收敛到 TypeORM 实体管理
- 安装 rxjs@7.8.2 依赖
2026-06-08 01:24:48 +08:00

783 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// binance.ts — Binance 交易所适配器
// ============================================================
// 基于 Binance 官方 SDKbinance@3.x)实现 MarketDataFeed 接口。
//
// WebSocket:使用 SDK 内置 WebsocketClient,自动处理多路复用、
// 断线重连、心跳保活。通过 formattedMessage 事件接收已解析的
// 类型化行情数据,转换为本系统标准化结构后通过 RxJS Subject 发布。
//
// REST:使用 SDK 内置 MainClientSpot),用于:
// - fetchKlines() 历史 K 线补拉
// - fetchMarkets() 交易对元数据(用于自动注册到 trading_pairs 表)
//
// ============================================================
// 风险提示:
// - Binance WebSocket 单连接最多订阅 1024 个 stream
// 超出需拆分多连接(SDK 自动处理)
// - 生产环境建议使用 Binance 的 combined streams 合并请求
// - REST API 限频:1200 请求/分钟(权重制),fetchKlines 权重 2
// ============================================================
import { Subject, type Observable } from "rxjs";
import {
WebsocketClient,
MainClient,
type WsMessageKlineFormatted,
type WsMessageTradeFormatted,
type WsMessage24hrTickerFormatted,
type WsMessageBookTickerEventFormatted,
type WsMessagePartialBookDepthEventFormatted,
type WsFormattedMessage,
} from "binance";
import type { Kline as BinanceRestKline } from "binance";
import { BaseExchangeAdapter } from "./base";
import { logger } from "../utils/logger";
import type {
Ticker,
Trade,
OrderBook,
Kline,
KlineInterval,
MarketInfo,
AdapterConfig,
BinanceRawKline,
} from "./types";
import { KLINE_INTERVAL_MS } from "./types";
// ============================================================
// Binance K 线周期 ← → 本系统 K 线周期映射
// ============================================================
/**
* Binance SDK 支持的 K 线周期(比本系统更多)。
* 本系统仅使用其中的子集,其余周期由 pipeline 合成。
*/
type BinanceKlineInterval =
| "1m"
| "5m"
| "15m"
| "30m"
| "1h"
| "4h"
| "1d"
| "1w";
/** 本系统 KlineInterval → Binance SDK KlineInterval1:1 子集映射) */
const INTERVAL_TO_BINANCE: Record<KlineInterval, BinanceKlineInterval> = {
"1m": "1m",
"5m": "5m",
"15m": "15m",
"30m": "30m",
"1h": "1h",
"4h": "4h",
"1d": "1d",
"1w": "1w",
};
// ============================================================
// 默认适配器配置(Binance 专用覆盖)
// ============================================================
const DEFAULT_BINANCE_CONFIG: AdapterConfig = {
reconnectBaseDelayMs: 3000,
maxReconnectAttempts: 10,
/** Binance REST API 权重制限频,保守设为 250ms */
restRateLimitMs: 250,
};
// ============================================================
// BinanceAdapter
// ============================================================
export class BinanceAdapter extends BaseExchangeAdapter {
readonly exchange = "binance";
// ----------------------------------------------------------
// SDK 客户端实例
// ----------------------------------------------------------
/** Binance WebSocket 客户端(内置多路复用 + 自动重连) */
private wsClient!: WebsocketClient;
/** Binance REST 客户端(Spot */
private restClient!: MainClient;
// ----------------------------------------------------------
// RxJS Subject —— 按事件类型分频道发布
// ----------------------------------------------------------
/** 24h Ticker 流(合并所有已订阅 symbol */
private tickerSubject!: Subject<Ticker>;
/** 逐笔成交流(合并所有已订阅 symbol) */
private tradeSubject!: Subject<Trade>;
/** 订单簿深度流(合并所有已订阅 symbol) */
private orderbookSubjects = new Map<string, Subject<OrderBook>>();
// ----------------------------------------------------------
// 订阅追踪
// ----------------------------------------------------------
/** 当前已订阅的 ticker symbol 集合 */
private subscribedTickerSymbols = new Set<string>();
/** 当前已订阅的 trade symbol 集合 */
private subscribedTradeSymbols = new Set<string>();
/** 当前已订阅的 orderbook symbol → depth 映射 */
private subscribedOrderbookDepths = new Map<string, number>();
/** 防止重复 REST 请求的节流 Mapsymbol:interval → lastFetchTime */
private lastRestFetch = new Map<string, number>();
// ============================================================
// 构造函数
// ============================================================
constructor(config: Partial<AdapterConfig> = {}) {
super({ ...DEFAULT_BINANCE_CONFIG, ...config });
}
// ============================================================
// 连接管理
// ============================================================
/**
* 建立 WebSocket 连接并注册事件监听。
*
* Binance SDK 的 WebsocketClient 在首次 subscribe() 时自动建连,
* 此处主动调用 connectPublic() 预热连接并注册 formattedMessage 监听。
*/
async connect(): Promise<void> {
if (this._connectionState === "connected") {
logger.debug(`[binance] 已连接,跳过重复 connect`);
return;
}
this.setConnectionState("connecting");
try {
// 初始化 WebSocket 客户端
this.wsClient = new WebsocketClient({
// 生产环境使用 Binance 主网
// useTestnet: false 为默认值
});
// 初始化 REST 客户端(公开接口无需 API Key)
this.restClient = new MainClient();
// 注册 formattedMessage 事件 —— SDK 将原始 JSON 解析为类型化对象
this.wsClient.on("formattedMessage", this.onFormattedMessage.bind(this));
// 注册重连事件(利用 SDK 内置的自动重连)
this.wsClient.on("reconnecting", (evt) => {
logger.warn(
{ wsKey: evt.wsKey },
`[binance] WebSocket 重连中...`,
);
this.setConnectionState("connecting");
});
this.wsClient.on("reconnected", (evt) => {
logger.info(
{ wsKey: evt.wsKey, wsUrl: evt.wsUrl },
`[binance] WebSocket 重连成功`,
);
this.setConnectionState("connected");
this.resetReconnectAttempts();
});
this.wsClient.on("close", (evt) => {
logger.warn(
{ wsKey: evt.wsKey },
`[binance] WebSocket 连接关闭`,
);
// 如果之前是已连接状态,SDK 会自动重连
if (this._connectionState === "connected") {
this.setConnectionState("connecting");
}
});
// 预热连接(SDK 连接到 spot 公开行情端点)
await this.wsClient.connectPublic();
this.setConnectionState("connected");
this.resetReconnectAttempts();
logger.info(`[binance] WebSocket 连接已建立`);
} catch (err) {
this.setConnectionState("error");
logger.error({ err }, `[binance] 连接失败`);
throw err;
}
}
/**
* 断开连接并清理资源。
*
* 1. 取消所有 WebSocket 订阅
* 2. 关闭 WebSocket 客户端
* 3. Complete 所有 RxJS Subject
*/
async disconnect(): Promise<void> {
logger.info(`[binance] 断开连接...`);
try {
// 取消所有订阅
if (this.wsClient && this.subscribedTradeSymbols.size > 0) {
const topics = [...this.subscribedTradeSymbols].map(
(s) => `${s.toLowerCase()}@trade`,
);
await this.wsClient.unsubscribe(topics, "main");
}
if (this.wsClient && this.subscribedTickerSymbols.size > 0) {
const topics = [...this.subscribedTickerSymbols].map(
(s) => `${s.toLowerCase()}@ticker`,
);
await this.wsClient.unsubscribe(topics, "main");
}
if (this.wsClient && this.subscribedOrderbookDepths.size > 0) {
for (const [symbol, depth] of this.subscribedOrderbookDepths) {
const topic = `${symbol.toLowerCase()}@depth${depth}@100ms`;
await this.wsClient.unsubscribe([topic], "main");
}
}
} catch (err) {
logger.warn({ err }, `[binance] 取消订阅时出错(忽略)`);
}
// 关闭 WS 客户端所有连接(SDK 自动关闭底层 WebSocket
try {
this.wsClient?.closeAll();
} catch {
// 忽略关闭错误
}
// Complete 所有 Subject
this.tickerSubject?.complete();
this.tradeSubject?.complete();
for (const subject of this.orderbookSubjects.values()) {
subject.complete();
}
this.orderbookSubjects.clear();
this.subscribedTickerSymbols.clear();
this.subscribedTradeSymbols.clear();
this.subscribedOrderbookDepths.clear();
this.setConnectionState("disconnected");
logger.info(`[binance] 已断开连接`);
}
// ============================================================
// 订阅 Ticker24h 滚动统计)
// ============================================================
subscribeTicker(symbols: string[]): Observable<Ticker> {
if (!this.tickerSubject) {
this.tickerSubject = this.createManagedSubject<Ticker>();
}
const newSymbols = symbols.filter(
(s) => !this.subscribedTickerSymbols.has(s),
);
if (newSymbols.length > 0 && this._connectionState === "connected") {
const topics = newSymbols.map(
(s) => `${s.toLowerCase()}@ticker`,
);
this.wsClient.subscribe(topics, "main").catch((err) => {
logger.error({ err, symbols: newSymbols }, `[binance] 订阅 ticker 失败`);
});
for (const s of newSymbols) {
this.subscribedTickerSymbols.add(s);
}
logger.info(
{ count: newSymbols.length, symbols: newSymbols },
`[binance] 订阅 ticker`,
);
}
return this.tickerSubject.asObservable();
}
// ============================================================
// 订阅逐笔成交
// ============================================================
subscribeTrade(symbols: string[]): Observable<Trade> {
if (!this.tradeSubject) {
this.tradeSubject = this.createManagedSubject<Trade>();
}
const newSymbols = symbols.filter(
(s) => !this.subscribedTradeSymbols.has(s),
);
if (newSymbols.length > 0 && this._connectionState === "connected") {
const topics = newSymbols.map(
(s) => `${s.toLowerCase()}@trade`,
);
this.wsClient.subscribe(topics, "main").catch((err) => {
logger.error({ err, symbols: newSymbols }, `[binance] 订阅 trade 失败`);
});
for (const s of newSymbols) {
this.subscribedTradeSymbols.add(s);
}
logger.info(
{ count: newSymbols.length, symbols: newSymbols },
`[binance] 订阅 trade`,
);
}
return this.tradeSubject.asObservable();
}
// ============================================================
// 订阅订单簿深度
// ============================================================
subscribeOrderbook(symbol: string, depth: number = 20): Observable<OrderBook> {
const key = `${symbol}@${depth}`;
let subject = this.orderbookSubjects.get(key);
if (subject) {
return subject.asObservable();
}
subject = this.createManagedSubject<OrderBook>();
this.orderbookSubjects.set(key, subject);
if (this._connectionState === "connected") {
const topic = `${symbol.toLowerCase()}@depth${depth}@100ms`;
this.wsClient.subscribe([topic], "main").catch((err) => {
logger.error({ err, symbol, depth }, `[binance] 订阅 orderbook 失败`);
});
this.subscribedOrderbookDepths.set(symbol, depth);
logger.info({ symbol, depth }, `[binance] 订阅 orderbook`);
}
return subject.asObservable();
}
// ============================================================
// REST:拉取历史 K 线(补缺失数据 / 回测)
// ============================================================
/**
* 通过 Binance REST API 拉取历史 K 线。
*
* Binance 限制:
* - 单次最多 1000 条(默认 500)
* - 权重 21200 权重/分钟 → 600 次请求/分钟)
* - 自动分页逻辑:如果时间跨度超过 limit 条,自动多次请求拼接
*
* @param symbol - 交易对(如 BTCUSDT
* @param interval - K 线周期
* @param startTime - 起始时间(Unix ms
* @param endTime - 结束时间(Unix ms
* @param limit - 单次最大条数(默认 500,最大 1000)
*/
async fetchKlines(
symbol: string,
interval: KlineInterval,
startTime: number,
endTime: number,
limit: number = 500,
): Promise<Kline[]> {
const binanceInterval = INTERVAL_TO_BINANCE[interval];
const intervalMs = KLINE_INTERVAL_MS[interval];
const maxLimit = Math.min(limit, 1000); // Binance 硬限制 1000
const allKlines: Kline[] = [];
let currentStart = startTime;
// 自动分页:如果时间跨度超过 maxLimit 条 K 线,分批拉取
while (currentStart < endTime) {
// 速率限制(保守节流)
const throttleKey = `${symbol}:${interval}`;
const lastFetch = this.lastRestFetch.get(throttleKey) ?? 0;
const elapsed = Date.now() - lastFetch;
if (elapsed < this.config.restRateLimitMs) {
await new Promise((r) =>
setTimeout(r, this.config.restRateLimitMs - elapsed),
);
}
this.lastRestFetch.set(throttleKey, Date.now());
try {
const rawKlines = await this.restClient.getKlines({
symbol,
interval: binanceInterval,
startTime: currentStart,
endTime,
limit: maxLimit,
});
if (!rawKlines || rawKlines.length === 0) {
break; // 无更多数据
}
// 转换 Binance REST K 线 → 本系统标准化 K 线
const converted = rawKlines.map((k) =>
this.convertRestKline(k, symbol, interval),
);
allKlines.push(...converted);
// Binance REST K 线格式:[openTime, open, high, low, close, volume, closeTime, ...]
// 最后一条的开盘时间 + interval 作为下一批的起点
const lastKline = rawKlines[rawKlines.length - 1]!;
const lastOpenTime = (lastKline as number[])[0] as number;
currentStart = lastOpenTime + intervalMs;
// 如果返回数量 < limit,说明已拉完
if (rawKlines.length < maxLimit) {
break;
}
} catch (err) {
logger.error(
{ err, symbol, interval, currentStart, endTime },
`[binance] fetchKlines 失败`,
);
throw err;
}
}
logger.debug(
{ symbol, interval, count: allKlines.length, startTime, endTime },
`[binance] fetchKlines 完成`,
);
return allKlines;
}
// ============================================================
// REST:拉取交易对元数据
// ============================================================
/**
* 从 Binance 获取所有现货交易对信息,转换为本系统 MarketInfo 格式。
*
* 用于自动注册到 trading_pairs 表,避免手动配置。
*/
async fetchMarkets(): Promise<MarketInfo[]> {
logger.info(`[binance] 拉取交易对信息...`);
try {
const exchangeInfo = await this.restClient.getExchangeInfo();
const markets: MarketInfo[] = [];
for (const symbolInfo of exchangeInfo.symbols) {
// 仅保留状态为 TRADING 的现货交易对
if (symbolInfo.status !== "TRADING") continue;
const filters = symbolInfo.filters;
// 从 filters 中提取交易规则
let tickSize: string | undefined;
let stepSize: string | undefined;
let minQty: string | undefined;
let minNotional: string | undefined;
for (const filter of filters) {
switch (filter.filterType) {
case "PRICE_FILTER":
tickSize = (filter as { tickSize: string }).tickSize;
break;
case "LOT_SIZE":
stepSize = (filter as { stepSize: string }).stepSize;
minQty = (filter as { minQty: string }).minQty;
break;
case "MIN_NOTIONAL":
case "NOTIONAL":
minNotional = (filter as { minNotional: string }).minNotional;
break;
}
}
markets.push({
symbol: symbolInfo.symbol,
baseAsset: symbolInfo.baseAsset,
quoteAsset: symbolInfo.quoteAsset,
pricePrecision: symbolInfo.quoteAssetPrecision,
quantityPrecision: symbolInfo.baseAssetPrecision,
minQty: minQty ? parseFloat(minQty) : undefined,
stepSize: stepSize ? parseFloat(stepSize) : undefined,
minNotional: minNotional ? parseFloat(minNotional) : undefined,
});
}
logger.info(
{ count: markets.length },
`[binance] 交易对信息拉取完成`,
);
return markets;
} catch (err) {
logger.error({ err }, `[binance] fetchMarkets 失败`);
throw err;
}
}
// ============================================================
// 内部:formattedMessage 事件分发
// ============================================================
/**
* Binance SDK formattedMessage 回调。
*
* SDK 已将原始 WebSocket JSON 解析为类型化事件对象。
* WsFormattedMessage 是复杂联合类型(含单事件 + 事件数组),
* TypeScript 的判别联合在此处不够精确。内部使用 `as any` 绕过
* 联合类型限制,按 eventType 字符串运行时路由。
*/
private onFormattedMessage(msg: WsFormattedMessage): void {
try {
// 数组类型(如 !ticker@arr → WsMessage24hrTickerFormatted[]
if (Array.isArray(msg)) {
for (const item of msg) {
this.routeByEventType(item as unknown as Record<string, unknown>);
}
return;
}
this.routeByEventType(msg as unknown as Record<string, unknown>);
} catch (err) {
const raw = msg as unknown as Record<string, unknown>;
const eventType = String(raw["eventType"] ?? "unknown");
logger.error(
{ err, eventType },
`[binance] 处理 formattedMessage 时出错`,
);
}
}
/**
* 按 eventType 运行时路由到对应 Subject。
*
* 此处使用 unknown → Record 转换,因为 WsFormattedMessage
* 联合类型包含数组成员导致无法直接访问 eventType。
*/
private routeByEventType(raw: Record<string, unknown>): void {
const eventType = String(raw["eventType"] ?? "");
if (!eventType) return;
switch (eventType) {
case "24hrTicker":
case "!ticker@arr":
this.handleTickerMessage(
raw as unknown as WsMessage24hrTickerFormatted,
);
break;
case "trade":
this.handleTradeMessage(
raw as unknown as WsMessageTradeFormatted,
);
break;
case "bookTicker":
this.handleBookTickerMessage(
raw as unknown as WsMessageBookTickerEventFormatted,
);
break;
case "partialBookDepth":
this.handleOrderbookMessage(
raw as unknown as WsMessagePartialBookDepthEventFormatted,
);
break;
case "kline":
// K 线事件不在 adapter 层分发,由 pipeline 的 KlineSynthesizer 处理
break;
default:
// 忽略其他事件类型(用户数据流、账户更新等)
break;
}
}
// ----------------------------------------------------------
// 事件转换器:Binance → 本系统标准化类型
// ----------------------------------------------------------
/** 24h Ticker → Ticker */
private handleTickerMessage(msg: WsMessage24hrTickerFormatted): void {
if (!this.tickerSubject || this.tickerSubject.closed) return;
const ticker: Ticker = {
exchange: "binance",
symbol: msg.symbol,
lastPrice: msg.currentClose,
openPrice: msg.open,
highPrice: msg.high,
lowPrice: msg.low,
volume: msg.baseAssetVolume,
quoteVolume: msg.quoteAssetVolume,
priceChange: msg.priceChange,
priceChangePercent: msg.priceChangePercent,
bidPrice: msg.bestBid,
bidQty: msg.bestBidQuantity,
askPrice: msg.bestAskPrice,
askQty: msg.bestAskQuantity,
eventTime: msg.eventTime,
closeTime: msg.closeTime,
};
this.tickerSubject.next(ticker);
}
/** 逐笔成交 → Trade */
private handleTradeMessage(msg: WsMessageTradeFormatted): void {
if (!this.tradeSubject || this.tradeSubject.closed) return;
const trade: Trade = {
exchange: "binance",
symbol: msg.symbol,
price: msg.price,
amount: msg.quantity,
quoteAmount: msg.price * msg.quantity,
timestamp: msg.time,
isBuyerMaker: msg.maker,
tradeId: String(msg.tradeId),
};
this.tradeSubject.next(trade);
}
/** BookTicker → Ticker(精简版,仅有最佳买卖价) */
private handleBookTickerMessage(msg: WsMessageBookTickerEventFormatted): void {
// BookTicker 是 Ticker 的精简版,仅更新最佳买卖价
// 如果有 tickerSubject,将其作为轻量 Ticker 推送
if (!this.tickerSubject || this.tickerSubject.closed) return;
const ticker: Ticker = {
exchange: "binance",
symbol: msg.symbol,
lastPrice: 0, // bookTicker 不含最新价
openPrice: 0,
highPrice: 0,
lowPrice: 0,
volume: 0,
quoteVolume: 0,
priceChange: 0,
priceChangePercent: 0,
bidPrice: msg.bidPrice,
bidQty: msg.bidQty,
askPrice: msg.askPrice,
askQty: msg.askQty,
eventTime: msg.eventTime,
closeTime: 0,
};
this.tickerSubject.next(ticker);
}
/** 订单簿深度快照 → OrderBook */
private handleOrderbookMessage(msg: WsMessagePartialBookDepthEventFormatted): void {
// partialBookDepth 不含 symbol 字段(取决于 SDK 版本),
// 从 stream 名称或上下文推断 symbol。此处假设 SDK 已填充 symbol。
const symbol = (msg as WsMessagePartialBookDepthEventFormatted & { symbol?: string }).symbol;
if (!symbol) {
logger.warn(`[binance] 收到无 symbol 的 orderbook 消息,丢弃`);
return;
}
// 查找匹配的 orderbook Subject(遍历所有已订阅 depth
for (const [key, subject] of this.orderbookSubjects) {
const [subscribedSymbol] = key.split("@");
if (
subscribedSymbol?.toUpperCase() === symbol.toUpperCase() &&
!subject.closed
) {
const orderbook: OrderBook = {
exchange: "binance",
symbol: symbol.toUpperCase(),
bids: msg.bids.map(
([price, qty]) => [parseFloat(String(price)), parseFloat(String(qty))] as [number, number],
),
asks: msg.asks.map(
([price, qty]) => [parseFloat(String(price)), parseFloat(String(qty))] as [number, number],
),
lastUpdateId: msg.lastUpdateId,
eventTime: Date.now(), // partialBookDepth 不含 eventTime
};
subject.next(orderbook);
return;
}
}
}
// ============================================================
// 内部:REST K 线格式转换
// ============================================================
/**
* 将 Binance REST K 线数组(元组)转换为本系统 Kline 对象。
*
* Binance REST K 线格式:
* [
* 0: openTime (ms),
* 1: open (string),
* 2: high (string),
* 3: low (string),
* 4: close (string),
* 5: volume (string),
* 6: closeTime (ms),
* 7: quoteVolume (string),
* 8: tradeCount (number),
* 9: takerBuyBaseVol (string),
* 10: takerBuyQuoteVol (string),
* 11: ignore (string)
* ]
*/
private convertRestKline(
raw: BinanceRestKline,
symbol: string,
interval: KlineInterval,
): Kline {
// BinanceRestKline 是元组类型,按位置索引
const arr = raw as unknown as [
number, // 0: openTime
string, // 1: open
string, // 2: high
string, // 3: low
string, // 4: close
string, // 5: volume
number, // 6: closeTime
string, // 7: quoteVolume
number, // 8: tradeCount
string, // 9: takerBuyBaseVol
string, // 10: takerBuyQuoteVol
string, // 11: ignore
];
return {
exchange: "binance",
symbol,
interval,
openTime: arr[0],
closeTime: arr[6],
open: parseFloat(arr[1]),
high: parseFloat(arr[2]),
low: parseFloat(arr[3]),
close: parseFloat(arr[4]),
volume: parseFloat(arr[5]),
quoteVolume: parseFloat(arr[7]),
takerBuyBaseVol: parseFloat(arr[9]),
takerBuyQuoteVol: parseFloat(arr[10]),
tradeCount: arr[8],
isClosed: true, // REST 返回的 K 线都是已闭合的
};
}
}
export default BinanceAdapter;