From 1d88ddefb58af9084a04045ec78bc016bc3612a9 Mon Sep 17 00:00:00 2001 From: discountry Date: Sat, 24 Jan 2026 23:53:36 +0800 Subject: [PATCH] Enhance account snapshot handling and staleness checks in MakerPointsEngine - Updated `emitAccountSnapshot` method in `StandxGateway` to accept an optional `updateTime` parameter, allowing for more accurate timestamping. - Introduced logic to determine the appropriate `updateTime` based on the latest position or balance data. - Added `time` property to `StandxPosition` interface for improved timestamp management. - Implemented `applyAccountSnapshot` method in `MakerPointsEngine` to streamline account snapshot processing and ensure accurate time tracking. - Added tests to validate the behavior of account staleness checks and defense mode activation based on account data freshness. --- src/exchanges/standx/gateway.ts | 13 +- src/exchanges/standx/types.ts | 1 + src/strategy/maker-points-engine.ts | 83 ++++++++--- ...maker-points-defense-account-stale.test.ts | 131 ++++++++++++++++++ 4 files changed, 203 insertions(+), 25 deletions(-) create mode 100644 tests/maker-points-defense-account-stale.test.ts diff --git a/src/exchanges/standx/gateway.ts b/src/exchanges/standx/gateway.ts index 87ac2f1..fdf6855 100644 --- a/src/exchanges/standx/gateway.ts +++ b/src/exchanges/standx/gateway.ts @@ -1398,7 +1398,7 @@ export class StandxGateway { } } - private emitAccountSnapshot(): void { + private emitAccountSnapshot(updateTime?: number): void { const positions = Array.from(this.positions.values()); const assets = Array.from(this.balances.values()); const totalWalletBalance = assets.reduce((sum, asset) => sum + Number(asset.walletBalance ?? 0), 0); @@ -1410,7 +1410,7 @@ export class StandxGateway { canTrade: true, canDeposit: true, canWithdraw: true, - updateTime: Date.now(), + updateTime: typeof updateTime === "number" && Number.isFinite(updateTime) && updateTime > 0 ? updateTime : Date.now(), totalWalletBalance: String(totalWalletBalance || 0), totalUnrealizedProfit: String(totalUnrealizedProfit || 0), positions, @@ -1433,10 +1433,13 @@ export class StandxGateway { this.requestJson("/api/query_balance", { method: "GET" }), this.requestJson("/api/query_positions", { method: "GET" }), ]); + let restSnapshotTime = 0; if (Array.isArray(positions)) { for (const position of positions) { const mapped = this.mapPosition(position); this.positions.set(mapped.symbol, mapped); + const positionTime = toTimestamp(position.time ?? position.updated_at); + restSnapshotTime = Math.max(restSnapshotTime, positionTime); } } if (balance) { @@ -1445,12 +1448,12 @@ export class StandxGateway { asset: token, walletBalance: String(balance.balance ?? "0"), availableBalance: String(balance.cross_available ?? balance.balance ?? "0"), - updateTime: Date.now(), + updateTime: restSnapshotTime > 0 ? restSnapshotTime : Date.now(), unrealizedProfit: String(balance.upnl ?? "0"), }; this.balances.set(token, asset); } - this.emitAccountSnapshot(); + this.emitAccountSnapshot(restSnapshotTime > 0 ? restSnapshotTime : undefined); return this.accountSnapshot; } catch (error) { this.logger("accountSnapshot", error); @@ -1679,7 +1682,7 @@ export class StandxGateway { entryPrice: String(data.entry_price ?? "0"), unrealizedProfit: String(data.upnl ?? "0"), positionSide: "BOTH", - updateTime: toTimestamp(data.updated_at), + updateTime: toTimestamp(data.time ?? data.updated_at), leverage: data.leverage ? String(data.leverage) : undefined, marginType: data.margin_mode, liquidationPrice: data.liq_price ? String(data.liq_price) : undefined, diff --git a/src/exchanges/standx/types.ts b/src/exchanges/standx/types.ts index a1980ad..0c62353 100644 --- a/src/exchanges/standx/types.ts +++ b/src/exchanges/standx/types.ts @@ -24,6 +24,7 @@ export interface StandxPosition { leverage?: string; liq_price?: string; margin_mode?: string; + time?: string; updated_at?: string; } diff --git a/src/strategy/maker-points-engine.ts b/src/strategy/maker-points-engine.ts index 2729fe3..3e82ae7 100644 --- a/src/strategy/maker-points-engine.ts +++ b/src/strategy/maker-points-engine.ts @@ -95,10 +95,11 @@ const STOP_LOSS_CHECK_INTERVAL_MS = 250; // 止损检查最大间隔 const STOP_LOSS_RETRY_INTERVAL_MS = 500; // 止损失败后重试间隔 const DATA_STALE_THRESHOLD_MS = 5_000; // 数据过时阈值(5秒) const DEFENSE_MODE_CHECK_INTERVAL_MS = 1000; // 防御模式检查间隔 -const ACCOUNT_DATA_STALE_THRESHOLD_MS = 20_000; // 账户数据长期无更新阈值(StandX 有 REST 兜底,长期无更新通常意味着异常) +const ACCOUNT_DATA_STALE_THRESHOLD_MS = 20_000; // 账户数据长期无更新阈值(会先尝试通过 REST 补拉验证,不直接进入防御模式) const STANDX_REST_ERROR_DEFENSE_THRESHOLD = 3; const STANDX_MARGIN_MODE_CHECK_INTERVAL_MS = 500; const STANDX_MARGIN_MODE_MAX_ATTEMPTS = 10; +const ACCOUNT_STALE_REST_PROBE_MIN_INTERVAL_MS = 5_000; export class MakerPointsEngine { private accountSnapshot: AsterAccountSnapshot | null = null; @@ -177,6 +178,9 @@ export class MakerPointsEngine { private lastStandxDepthTime = 0; private lastStandxAccountTime = 0; private lastBinanceDepthTime = 0; + private accountStaleRestProbeInFlight: Promise | null = null; + private accountStaleRestProbeLastAttempt = 0; + private accountStaleRestProbeConsecutiveFailures = 0; // 防御模式状态 private defenseMode = false; private defenseModeNotified = false; @@ -289,17 +293,7 @@ export class MakerPointsEngine { safeSubscribe( this.exchange.watchAccount.bind(this.exchange), (snapshot) => { - this.accountSnapshot = snapshot; - this.lastStandxAccountTime = Date.now(); - const totalUnrealized = Number(snapshot.totalUnrealizedProfit ?? "0"); - if (Number.isFinite(totalUnrealized)) { - this.accountUnrealized = totalUnrealized; - } - const position = getPosition(snapshot, this.config.symbol); - this.sessionVolume.update(position, this.getReferencePrice()); - this.detectPositionChange(position); - this.feedStatus.account = true; - this.emitUpdate(); + this.applyAccountSnapshot(snapshot); }, log, { @@ -370,6 +364,51 @@ export class MakerPointsEngine { this.setupConnectionProtection(); } + private applyAccountSnapshot(snapshot: AsterAccountSnapshot): void { + this.accountSnapshot = snapshot; + // StandX: WS 推送使用本地接收时间戳;REST 快照使用响应里的 time 字段映射到 snapshot.updateTime + this.lastStandxAccountTime = + this.exchange.id === "standx" && Number.isFinite(snapshot.updateTime) && snapshot.updateTime > 0 + ? snapshot.updateTime + : Date.now(); + this.accountStaleRestProbeConsecutiveFailures = 0; + const totalUnrealized = Number(snapshot.totalUnrealizedProfit ?? "0"); + if (Number.isFinite(totalUnrealized)) { + this.accountUnrealized = totalUnrealized; + } + const position = getPosition(snapshot, this.config.symbol); + this.sessionVolume.update(position, this.getReferencePrice()); + this.detectPositionChange(position); + this.feedStatus.account = true; + this.emitUpdate(); + } + + private maybeProbeStandxAccountSnapshot(now: number): void { + if (this.exchange.id !== "standx") return; + if (!this.exchange.queryAccountSnapshot) return; + if (this.defenseMode) return; + if (this.accountStaleRestProbeInFlight) return; + if (this.accountStaleRestProbeLastAttempt > 0 && now - this.accountStaleRestProbeLastAttempt < ACCOUNT_STALE_REST_PROBE_MIN_INTERVAL_MS) { + return; + } + this.accountStaleRestProbeLastAttempt = now; + + this.accountStaleRestProbeInFlight = (async () => { + try { + const next = await this.exchange.queryAccountSnapshot?.(); + if (next) { + this.applyAccountSnapshot(next); + } else { + this.accountStaleRestProbeConsecutiveFailures += 1; + } + } catch { + this.accountStaleRestProbeConsecutiveFailures += 1; + } finally { + this.accountStaleRestProbeInFlight = null; + } + })(); + } + /** * 设置连接保护机制 * 监听断连/重连事件,实现保护逻辑 @@ -1616,8 +1655,16 @@ export class MakerPointsEngine { const standxDepthStale = this.lastStandxDepthTime > 0 && (now - this.lastStandxDepthTime) > DATA_STALE_THRESHOLD_MS; const binanceStale = this.lastBinanceDepthTime > 0 && (now - this.lastBinanceDepthTime) > DATA_STALE_THRESHOLD_MS; + const standxAccountAge = this.lastStandxAccountTime > 0 ? now - this.lastStandxAccountTime : 0; + const standxAccountStaleByAge = this.lastStandxAccountTime > 0 && standxAccountAge > ACCOUNT_DATA_STALE_THRESHOLD_MS; + if (standxAccountStaleByAge) { + this.maybeProbeStandxAccountSnapshot(now); + } const standxAccountStale = - this.lastStandxAccountTime > 0 && (now - this.lastStandxAccountTime) > ACCOUNT_DATA_STALE_THRESHOLD_MS; + standxAccountStaleByAge && + // WS 推送间隔可能较长,先给 REST 补拉一次机会;只有补拉失败后才进入防御模式 + this.accountStaleRestProbeConsecutiveFailures > 0 && + this.accountStaleRestProbeInFlight == null; const accountHealth = validateAccountSnapshotForSymbol(this.accountSnapshot, this.config.symbol); const accountInvalid = this.accountSnapshot != null && !accountHealth.ok; const standxRestUnhealthy = @@ -1647,7 +1694,7 @@ export class MakerPointsEngine { marginMode, standxDepthAge: this.lastStandxDepthTime > 0 ? now - this.lastStandxDepthTime : 0, binanceAge: this.lastBinanceDepthTime > 0 ? now - this.lastBinanceDepthTime : 0, - standxAccountAge: this.lastStandxAccountTime > 0 ? now - this.lastStandxAccountTime : 0, + standxAccountAge, accountIssues: accountInvalid ? accountHealth.issues : [], }); } else if (!shouldDefend && this.defenseMode) { @@ -1797,9 +1844,7 @@ export class MakerPointsEngine { if (this.exchange.queryAccountSnapshot) { const nextAccount = await this.exchange.queryAccountSnapshot(); if (nextAccount) { - this.accountSnapshot = nextAccount; - this.lastStandxAccountTime = Date.now(); - this.feedStatus.account = true; + this.applyAccountSnapshot(nextAccount); const health = validateAccountSnapshotForSymbol(nextAccount, this.config.symbol); if (!health.ok) { this.tradeLog.push("warn", `防御模式: 仓位数据仍异常: ${health.issues.join(",")}`); @@ -1884,9 +1929,7 @@ export class MakerPointsEngine { for (let attempt = 0; attempt < STANDX_MARGIN_MODE_MAX_ATTEMPTS; attempt++) { const next = await queryAccount(); if (next) { - this.accountSnapshot = next; - this.lastStandxAccountTime = Date.now(); - this.feedStatus.account = true; + this.applyAccountSnapshot(next); } const mode = this.getStandxMarginMode(this.accountSnapshot); if (mode === "isolated") { diff --git a/tests/maker-points-defense-account-stale.test.ts b/tests/maker-points-defense-account-stale.test.ts new file mode 100644 index 0000000..6d219a2 --- /dev/null +++ b/tests/maker-points-defense-account-stale.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ExchangeAdapter } from "../src/exchanges/adapter"; +import type { AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker } from "../src/exchanges/types"; +import { MakerPointsEngine } from "../src/strategy/maker-points-engine"; + +class StubAdapter implements ExchangeAdapter { + id = "standx"; + accountSnapshot: AsterAccountSnapshot | null = null; + + supportsTrailingStops(): boolean { + return false; + } + + watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {} + watchOrders(_cb: (orders: AsterOrder[]) => void): void {} + watchDepth(_symbol: string, _cb: (depth: AsterDepth) => void): void {} + watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {} + watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {} + + async createOrder(): Promise { + throw new Error("not implemented"); + } + + async cancelOrder(): Promise {} + async cancelOrders(): Promise {} + async cancelAllOrders(): Promise {} + + async queryAccountSnapshot(): Promise { + return this.accountSnapshot; + } +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("MakerPointsEngine defense-mode account staleness", () => { + it("does not enter defense mode for ~21s StandX account gap (REST probe succeeds)", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-24T15:20:00.000Z")); + const adapter = new StubAdapter(); + adapter.accountSnapshot = { + canTrade: true, + canDeposit: true, + canWithdraw: true, + updateTime: Date.now(), + totalWalletBalance: "0", + totalUnrealizedProfit: "0", + positions: [], + assets: [], + marketType: "perp", + }; + const engine = new MakerPointsEngine( + { + symbol: "BTC-USD", + perOrderAmount: 0.01, + closeThreshold: 0, + stopLossUsd: 1, + refreshIntervalMs: 500, + maxLogEntries: 10, + maxCloseSlippagePct: 0.05, + priceTick: 0.1, + qtyStep: 0.001, + enableBand0To10: true, + enableBand10To30: false, + enableBand30To100: false, + band0To10Amount: 0.01, + band10To30Amount: 0.01, + band30To100Amount: 0.01, + minRepriceBps: 3, + enableBinanceDepthCancel: false, + filterMinDepth: 0, + }, + adapter + ); + + const now = Date.now(); + (engine as any).lastStandxDepthTime = now; + (engine as any).lastBinanceDepthTime = now; + (engine as any).lastStandxAccountTime = now - 21_000; + + (engine as any).checkDataStaleAndDefense(); + expect((engine as any).defenseMode).toBe(false); + await vi.runAllTimersAsync(); + engine.stop(); + }); + + it("enters defense mode if StandX account REST probe fails", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-24T15:20:00.000Z")); + const adapter = new StubAdapter(); + adapter.accountSnapshot = null; + const engine = new MakerPointsEngine( + { + symbol: "BTC-USD", + perOrderAmount: 0.01, + closeThreshold: 0, + stopLossUsd: 1, + refreshIntervalMs: 500, + maxLogEntries: 10, + maxCloseSlippagePct: 0.05, + priceTick: 0.1, + qtyStep: 0.001, + enableBand0To10: true, + enableBand10To30: false, + enableBand30To100: false, + band0To10Amount: 0.01, + band10To30Amount: 0.01, + band30To100Amount: 0.01, + minRepriceBps: 3, + enableBinanceDepthCancel: false, + filterMinDepth: 0, + }, + adapter + ); + + const now = Date.now(); + (engine as any).lastStandxDepthTime = now; + (engine as any).lastBinanceDepthTime = now; + (engine as any).lastStandxAccountTime = now - 121_000; + + (engine as any).checkDataStaleAndDefense(); + expect((engine as any).defenseMode).toBe(false); + + await vi.runAllTimersAsync(); + vi.advanceTimersByTime(1000); + (engine as any).checkDataStaleAndDefense(); + expect((engine as any).defenseMode).toBe(true); + engine.stop(); + }); +});