Enhance WebSocket and API documentation; implement connection protection features

- Added a note in the HTTP API documentation regarding the non-guaranteed sequence of price levels in order book responses.
- Updated WebSocket documentation to include a connection duration limit and a note on local sorting requirements for price levels.
- Introduced connection event handling in the ExchangeAdapter interface, allowing for disconnection and reconnection events.
- Implemented connection protection logic in the StandxExchangeAdapter and MakerPointsEngine to manage order states during connection disruptions.
- Enhanced the StandxGateway with methods for querying open orders and forcefully canceling all orders, improving reliability during network issues.
This commit is contained in:
discountry
2026-01-16 10:49:16 +08:00
parent d493642935
commit aa24995d28
6 changed files with 411 additions and 5 deletions
+10
View File
@@ -47,6 +47,11 @@ export interface ExchangePrecision {
minQuoteAmount?: number;
}
export type ConnectionEventType = "disconnected" | "reconnected";
export interface ConnectionEventListener {
(event: ConnectionEventType, symbol: string): void;
}
export interface ExchangeAdapter {
readonly id: string;
supportsTrailingStops(): boolean;
@@ -61,4 +66,9 @@ export interface ExchangeAdapter {
cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void>;
cancelAllOrders(params: { symbol: string }): Promise<void>;
getPrecision?(): Promise<ExchangePrecision | null>;
// 连接保护相关方法(可选,仅 StandX 支持)
onConnectionEvent?(listener: ConnectionEventListener): void;
offConnectionEvent?(listener: ConnectionEventListener): void;
queryOpenOrders?(): Promise<AsterOrder[]>;
forceCancelAllOrders?(): Promise<boolean>;
}
+35 -1
View File
@@ -11,7 +11,9 @@ import type {
} from "../adapter";
import type { AsterOrder, CreateOrderParams } from "../types";
import { extractMessage } from "../../utils/errors";
import { StandxGateway, type StandxGatewayOptions } from "./gateway";
import { StandxGateway, type StandxGatewayOptions, type ConnectionEventListener, type ConnectionEventType } from "./gateway";
export type { ConnectionEventListener, ConnectionEventType };
export interface StandxCredentials {
token?: string;
@@ -122,6 +124,38 @@ export class StandxExchangeAdapter implements ExchangeAdapter {
}
}
/**
* 监听连接事件(断连/重连)
*/
onConnectionEvent(listener: ConnectionEventListener): void {
this.gateway.onConnectionEvent(listener);
}
/**
* 取消连接事件监听
*/
offConnectionEvent(listener: ConnectionEventListener): void {
this.gateway.offConnectionEvent(listener);
}
/**
* 查询当前真实的挂单状态(通过 HTTP API)
* 用于验证实际挂单情况,防止取消请求丢失
*/
async queryOpenOrders(): Promise<AsterOrder[]> {
await this.ensureInitialized("queryOpenOrders");
return this.gateway.queryOpenOrders(this.symbol);
}
/**
* 强制取消所有挂单
* 会查询当前挂单然后取消,并验证取消成功
*/
async forceCancelAllOrders(): Promise<boolean> {
await this.ensureInitialized("forceCancelAllOrders");
return this.gateway.forceCancelAllOrders(this.symbol);
}
private safeInvoke<T extends (...args: any[]) => void>(context: string, cb: T): T {
const wrapped = ((...args: any[]) => {
try {
+174
View File
@@ -80,6 +80,9 @@ export interface StandxGatewayOptions {
logger?: (context: string, error: unknown) => void;
}
export type ConnectionEventType = "disconnected" | "reconnected";
export type ConnectionEventListener = (event: ConnectionEventType, symbol: string) => void;
class StandxRequestSigner {
private readonly privateKey: Uint8Array | null;
@@ -396,6 +399,7 @@ export class StandxGateway {
private readonly tickerListeners = new Map<string, Set<TickerListener>>();
private readonly klineListeners = new Map<string, Set<KlineListener>>();
private readonly fundingListeners = new Map<string, Set<FundingRateListener>>();
private readonly connectionListeners = new Set<ConnectionEventListener>();
private readonly openOrders = new Map<string, AsterOrder>();
private readonly positions = new Map<string, AsterAccountPosition>();
@@ -417,6 +421,12 @@ export class StandxGateway {
private lastPriceBySymbol = new Map<string, number>();
// 断连保护相关
private disconnectCancelRetryTimer: ReturnType<typeof setTimeout> | null = null;
private disconnectCancelRetryActive = false;
private lastKnownOpenOrders: Array<{ orderId: string; clOrdId?: string }> = [];
private disconnectedSymbol: string | null = null;
constructor(options: StandxGatewayOptions) {
this.token = options.token ?? process.env.STANDX_TOKEN ?? "";
if (!this.token) {
@@ -511,6 +521,54 @@ export class StandxGateway {
this.startFundingPolling(key);
}
onConnectionEvent(listener: ConnectionEventListener): void {
this.connectionListeners.add(listener);
}
offConnectionEvent(listener: ConnectionEventListener): void {
this.connectionListeners.delete(listener);
}
/**
* 查询当前真实的挂单状态(通过 HTTP API)
* 用于在网络恢复后验证实际挂单情况
*/
async queryOpenOrders(symbol: string): Promise<AsterOrder[]> {
const normalized = normalizeSymbol(symbol);
const ordersPayload = await this.requestJson<unknown>("/api/query_open_orders", {
method: "GET",
params: { symbol: normalized },
});
const orders = extractOrders(ordersPayload);
const result: AsterOrder[] = [];
for (const raw of orders) {
const order = this.mapOrder(raw);
result.push(order);
}
return result;
}
/**
* 强制取消所有挂单(用于断连保护)
* 会不断重试直到成功或确认没有挂单
*/
async forceCancelAllOrders(symbol: string): Promise<boolean> {
const normalized = normalizeSymbol(symbol);
try {
const currentOrders = await this.queryOpenOrders(normalized);
if (currentOrders.length === 0) {
return true;
}
await this.cancelAllOrders({ symbol: normalized });
// 再次查询确认
const afterCancel = await this.queryOpenOrders(normalized);
return afterCancel.length === 0;
} catch (error) {
this.logger("forceCancelAllOrders", error);
return false;
}
}
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
const normalizedSymbol = normalizeSymbol(params.symbol);
if (params.type === "STOP_MARKET") {
@@ -791,11 +849,16 @@ export class StandxGateway {
this.sendAuthIfNeeded();
};
const handleClose = () => {
const wasReady = this.marketWsReady;
this.marketWsReady = false;
this.marketWsAuthed = false;
this.marketWsAuthRequested = false;
this.marketWs = null;
this.logDebug("ws close");
// 触发断连事件,启动断连保护
if (wasReady) {
this.onDisconnect();
}
this.scheduleReconnect();
};
const handleError = (error: unknown) => {
@@ -854,6 +917,8 @@ export class StandxGateway {
this.marketWsAuthed = true;
this.marketWsAuthRequested = false;
this.flushSubscriptions();
// 触发重连事件
this.onReconnect();
}
return;
}
@@ -1393,4 +1458,113 @@ export class StandxGateway {
return text as unknown as T;
}
}
/**
* 断连时触发,记录当前挂单状态并启动持久重试取消
*/
private onDisconnect(): void {
// 记录最后已知的挂单状态
this.lastKnownOpenOrders = Array.from(this.openOrders.values()).map((order) => ({
orderId: String(order.orderId),
clOrdId: order.clientOrderId,
}));
// 获取当前订阅的 symbol
const symbols = new Set<string>();
for (const key of this.subscriptions) {
const [, symbol] = key.split(":");
if (symbol) symbols.add(symbol);
}
this.disconnectedSymbol = symbols.size > 0 ? Array.from(symbols)[0] ?? null : null;
this.logDebug("disconnect protection", {
openOrderCount: this.lastKnownOpenOrders.length,
symbol: this.disconnectedSymbol,
});
// 触发断连事件
for (const listener of this.connectionListeners) {
try {
listener("disconnected", this.disconnectedSymbol ?? "");
} catch (error) {
this.logger("connectionListener", error);
}
}
// 启动断连保护:持续重试取消所有挂单
if (this.lastKnownOpenOrders.length > 0 && this.disconnectedSymbol) {
this.startDisconnectCancelRetry(this.disconnectedSymbol);
}
}
/**
* 重连成功时触发,停止断连保护并通知监听器
*/
private onReconnect(): void {
this.logDebug("reconnect protection", {
wasRetrying: this.disconnectCancelRetryActive,
symbol: this.disconnectedSymbol,
});
// 停止断连保护重试
this.stopDisconnectCancelRetry();
// 触发重连事件
for (const listener of this.connectionListeners) {
try {
listener("reconnected", this.disconnectedSymbol ?? "");
} catch (error) {
this.logger("connectionListener", error);
}
}
this.disconnectedSymbol = null;
this.lastKnownOpenOrders = [];
}
/**
* 启动断连保护:持续重试取消所有挂单
* 即使网络不通也不停止重试
*/
private startDisconnectCancelRetry(symbol: string): void {
if (this.disconnectCancelRetryActive) return;
this.disconnectCancelRetryActive = true;
const retryCancel = async () => {
if (!this.disconnectCancelRetryActive) return;
this.logDebug("disconnect cancel retry attempt", { symbol });
try {
const success = await this.forceCancelAllOrders(symbol);
if (success) {
this.logDebug("disconnect cancel retry success");
this.stopDisconnectCancelRetry();
return;
}
} catch (error) {
this.logger("disconnectCancelRetry", error);
}
// 如果仍在重试状态,继续下一次重试
if (this.disconnectCancelRetryActive) {
this.disconnectCancelRetryTimer = setTimeout(() => {
void retryCancel();
}, 2000); // 每 2 秒重试一次
}
};
void retryCancel();
}
/**
* 停止断连保护重试
*/
private stopDisconnectCancelRetry(): void {
this.disconnectCancelRetryActive = false;
if (this.disconnectCancelRetryTimer) {
clearTimeout(this.disconnectCancelRetryTimer);
this.disconnectCancelRetryTimer = null;
}
}
}
+179 -2
View File
@@ -1,5 +1,5 @@
import type { MakerPointsConfig } from "../config";
import type { ExchangeAdapter } from "../exchanges/adapter";
import type { ExchangeAdapter, ConnectionEventType } from "../exchanges/adapter";
import type {
AsterAccountSnapshot,
AsterDepth,
@@ -149,6 +149,12 @@ export class MakerPointsEngine {
private lastPositionAmt = 0;
private lastPositionSide: "LONG" | "SHORT" | "FLAT" = "FLAT";
// 连接保护相关状态
private connectionState: "connected" | "disconnected" = "connected";
private reconnectResetPending = false;
private lastRepriceQueryTime = 0;
private readonly repriceQueryIntervalMs = 3000; // 最小查询间隔
constructor(private readonly config: MakerPointsConfig, private readonly exchange: ExchangeAdapter) {
this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
@@ -290,6 +296,101 @@ export class MakerPointsEngine {
processFail: (error) => t("log.process.tickerError", { error: String(error) }),
}
);
// 注册连接事件监听(如果交易所支持)
this.setupConnectionProtection();
}
/**
* 设置连接保护机制
* 监听断连/重连事件,实现保护逻辑
*/
private setupConnectionProtection(): void {
if (!this.exchange.onConnectionEvent) return;
this.exchange.onConnectionEvent((event, symbol) => {
if (event === "disconnected") {
this.handleDisconnect(symbol);
} else if (event === "reconnected") {
this.handleReconnect(symbol);
}
});
}
/**
* 处理断连事件
*/
private handleDisconnect(symbol: string): void {
this.connectionState = "disconnected";
this.tradeLog.push("warn", `WebSocket 断连 (${symbol}),启动断连保护`);
this.notify({
type: "token_expired",
level: "warn",
symbol: this.config.symbol,
title: "连接断开",
message: "WebSocket 断连,正在尝试取消所有挂单",
details: { symbol },
});
}
/**
* 处理重连事件
* 重连后需要重新查询挂单并取消所有挂单
*/
private async handleReconnect(symbol: string): Promise<void> {
this.connectionState = "connected";
this.reconnectResetPending = true;
this.tradeLog.push("info", `WebSocket 重连成功 (${symbol}),开始重连保护流程`);
try {
// 查询真实挂单状态
if (this.exchange.queryOpenOrders) {
const realOrders = await this.exchange.queryOpenOrders();
this.tradeLog.push("info", `重连后查询到 ${realOrders.length} 个挂单`);
if (realOrders.length > 0) {
// 取消所有挂单
if (this.exchange.forceCancelAllOrders) {
const success = await this.exchange.forceCancelAllOrders();
if (success) {
this.tradeLog.push("order", "重连保护:已取消所有挂单");
} else {
this.tradeLog.push("warn", "重连保护:取消挂单未完全成功,将在下次循环重试");
}
} else {
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
this.tradeLog.push("order", "重连保护:已取消所有挂单");
}
}
}
// 重置本地挂单状态
this.openOrders = [];
this.pendingCancelOrders.clear();
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
// 重置 reprice 基准,强制下一次重新计算
this.lastQuoteBid1 = null;
this.lastQuoteAsk1 = null;
this.desiredOrders = [];
this.lastDesiredSummary = null;
// 标记启动重置需要重新执行
this.initialOrderResetDone = false;
this.notify({
type: "position_opened",
level: "info",
symbol: this.config.symbol,
title: "重连完成",
message: "WebSocket 重连成功,已清理挂单状态",
details: { symbol },
});
} catch (error) {
this.tradeLog.push("error", `重连保护流程失败: ${extractMessage(error)}`);
} finally {
this.reconnectResetPending = false;
}
}
private syncLocksWithOrders(orders: AsterOrder[] | null | undefined): void {
@@ -553,7 +654,14 @@ export class MakerPointsEngine {
}
}
private async syncOrders(targets: DesiredOrder[], closeOnly: boolean): Promise<void> {
private async syncOrders(targets: DesiredOrder[], _closeOnly: boolean): Promise<void> {
// 价格变化保护:如果需要 reprice 且距上次查询已过足够时间,先查询真实挂单
const shouldVerifyOrders = await this.verifyOrdersIfNeeded();
if (shouldVerifyOrders) {
// 如果发现有未预期的挂单,先取消所有挂单
return;
}
const availableOrders = this.openOrders.filter((o) => !this.pendingCancelOrders.has(String(o.orderId)));
const openOrders = availableOrders.filter((order) => isOrderActiveStatus(order.status));
const { toCancel, toPlace } = makeOrderPlan(openOrders, targets);
@@ -629,6 +737,75 @@ export class MakerPointsEngine {
}
}
/**
* 验证真实挂单状态,防止取消请求丢失
* 在每次 reprice 时查询真实挂单,发现未预期的挂单时取消所有挂单
* @returns true 表示发现问题并执行了取消操作,调用方应跳过本轮挂单
*/
private async verifyOrdersIfNeeded(): Promise<boolean> {
// 如果交易所不支持查询挂单,跳过验证
if (!this.exchange.queryOpenOrders) return false;
// 限制查询频率
const now = Date.now();
if (now - this.lastRepriceQueryTime < this.repriceQueryIntervalMs) {
return false;
}
try {
const realOrders = await this.exchange.queryOpenOrders();
this.lastRepriceQueryTime = now;
// 比较真实挂单与本地记录
const realOrderIds = new Set(realOrders.map((o) => String(o.orderId)));
const localOrderIds = new Set(this.openOrders.map((o) => String(o.orderId)));
// 查找本地以为已取消但实际还存在的订单
const unexpectedOrders = realOrders.filter((order) => {
const orderId = String(order.orderId);
// 如果本地没有这个订单,说明我们以为它已经被取消了
if (!localOrderIds.has(orderId)) {
return true;
}
// 如果本地记录这个订单在等待取消,但实际还存在
if (this.pendingCancelOrders.has(orderId)) {
return true;
}
return false;
});
if (unexpectedOrders.length > 0) {
this.tradeLog.push(
"warn",
`发现 ${unexpectedOrders.length} 个未预期挂单,执行强制取消`
);
// 强制取消所有挂单
if (this.exchange.forceCancelAllOrders) {
await this.exchange.forceCancelAllOrders();
} else {
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
}
// 重置本地状态
this.openOrders = [];
this.pendingCancelOrders.clear();
this.tradeLog.push("order", "已强制取消所有挂单,重置本地状态");
return true;
}
// 更新本地挂单状态以匹配真实状态
if (realOrders.length !== this.openOrders.length) {
// 移除本地记录中不存在于服务器的订单
this.openOrders = this.openOrders.filter((o) => realOrderIds.has(String(o.orderId)));
}
} catch (error) {
this.tradeLog.push("error", `验证挂单状态失败: ${extractMessage(error)}`);
}
return false;
}
private async checkStopLoss(): Promise<void> {
if (this.stopLossProcessing) return;
const lossLimit = Number(this.config.stopLossUsd);