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.
This commit is contained in:
discountry
2026-01-24 23:53:36 +08:00
parent 683352f737
commit 1d88ddefb5
4 changed files with 203 additions and 25 deletions
+8 -5
View File
@@ -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<StandxBalanceSnapshot>("/api/query_balance", { method: "GET" }),
this.requestJson<StandxPosition[]>("/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,
+1
View File
@@ -24,6 +24,7 @@ export interface StandxPosition {
leverage?: string;
liq_price?: string;
margin_mode?: string;
time?: string;
updated_at?: string;
}
+63 -20
View File
@@ -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<void> | null = null;
private accountStaleRestProbeLastAttempt = 0;
private accountStaleRestProbeConsecutiveFailures = 0;
// 防御模式状态
private defenseMode = false;
private defenseModeNotified = false;
@@ -289,17 +293,7 @@ export class MakerPointsEngine {
safeSubscribe<AsterAccountSnapshot>(
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") {
@@ -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<AsterOrder> {
throw new Error("not implemented");
}
async cancelOrder(): Promise<void> {}
async cancelOrders(): Promise<void> {}
async cancelAllOrders(): Promise<void> {}
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
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();
});
});