mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface StandxPosition {
|
||||
leverage?: string;
|
||||
liq_price?: string;
|
||||
margin_mode?: string;
|
||||
time?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user