85a0031a78
- 新增 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 依赖
783 lines
28 KiB
TypeScript
783 lines
28 KiB
TypeScript
// ============================================================
|
||
// binance.ts — Binance 交易所适配器
|
||
// ============================================================
|
||
// 基于 Binance 官方 SDK(binance@3.x)实现 MarketDataFeed 接口。
|
||
//
|
||
// WebSocket:使用 SDK 内置 WebsocketClient,自动处理多路复用、
|
||
// 断线重连、心跳保活。通过 formattedMessage 事件接收已解析的
|
||
// 类型化行情数据,转换为本系统标准化结构后通过 RxJS Subject 发布。
|
||
//
|
||
// REST:使用 SDK 内置 MainClient(Spot),用于:
|
||
// - 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 KlineInterval(1: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 请求的节流 Map(symbol: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] 已断开连接`);
|
||
}
|
||
|
||
// ============================================================
|
||
// 订阅 Ticker(24h 滚动统计)
|
||
// ============================================================
|
||
|
||
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)
|
||
* - 权重 2(1200 权重/分钟 → 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;
|