diff --git a/src/strategy/common/isolated-margin-guard.ts b/src/strategy/common/isolated-margin-guard.ts new file mode 100644 index 0000000..81cda74 --- /dev/null +++ b/src/strategy/common/isolated-margin-guard.ts @@ -0,0 +1,91 @@ +import type { AccountSnapshot } from "../../exchanges/types"; +import { extractMessage } from "../../utils/errors"; +import type { LogHandler } from "./subscriptions"; + +/** Snapshot refreshes to wait through before giving up on the switch (~5s total). */ +const MAX_CONFIRM_ATTEMPTS = 10; +const CONFIRM_INTERVAL_MS = 500; + +export interface IsolatedMarginGuardDeps { + symbol: string; + /** False on venues that do not expose a per-symbol margin mode; the guard is then inert. */ + enabled: boolean; + log: LogHandler; + /** Latest account snapshot the engine holds. */ + currentSnapshot: () => AccountSnapshot | null; + changeMarginMode?: (params: { symbol: string; marginMode: "isolated" | "cross" }) => Promise; + queryAccountSnapshot?: () => Promise; + /** Feeds a freshly polled snapshot back into the engine before re-reading the mode. */ + applySnapshot: (snapshot: AccountSnapshot) => void; + sleep?: (ms: number) => Promise; +} + +/** + * Keeps the traded symbol on isolated margin. + * + * The switch is asynchronous at the venue: the REST call returns before the + * account reflects it, so the guard polls until the new mode shows up. A single + * in-flight promise makes concurrent ticks share one attempt instead of firing + * the change repeatedly. + */ +export class IsolatedMarginGuard { + private ensuring: Promise | null = null; + + constructor(private readonly deps: IsolatedMarginGuardDeps) {} + + /** The venue's margin mode for this symbol, lowercased, or null when unknown. */ + currentMode(snapshot: AccountSnapshot | null = this.deps.currentSnapshot()): string | null { + if (!this.deps.enabled) return null; + const positions = snapshot?.positions ?? []; + const match = positions.find((pos) => pos.symbol === this.deps.symbol); + const raw = (match as { marginType?: unknown; margin_mode?: unknown } | undefined)?.marginType ?? + (match as { margin_mode?: unknown } | undefined)?.margin_mode; + const mode = typeof raw === "string" ? raw.trim().toLowerCase() : ""; + return mode ? mode : null; + } + + /** + * @returns true when the symbol is on isolated margin. A false result means the + * caller should hold off trading — either a switch is under way or it failed. + */ + async ensureIsolated(): Promise { + if (!this.deps.enabled) return true; + if (this.currentMode() === "isolated") return true; + + const { changeMarginMode, queryAccountSnapshot } = this.deps; + if (!changeMarginMode || !queryAccountSnapshot) return false; + // Another tick is already switching; do not stack a second request. + if (this.ensuring) return false; + + this.ensuring = this.performSwitch(changeMarginMode, queryAccountSnapshot); + return await this.ensuring; + } + + private async performSwitch( + changeMarginMode: NonNullable, + queryAccountSnapshot: NonNullable + ): Promise { + const sleep = this.deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + try { + await changeMarginMode({ symbol: this.deps.symbol, marginMode: "isolated" }); + for (let attempt = 0; attempt < MAX_CONFIRM_ATTEMPTS; attempt += 1) { + const next = await queryAccountSnapshot(); + if (next) { + this.deps.applySnapshot(next); + } + if (this.currentMode() === "isolated") { + this.deps.log("info", "已切换为逐仓模式 (isolated),恢复策略运行"); + return true; + } + await sleep(CONFIRM_INTERVAL_MS); + } + this.deps.log("warn", `逐仓模式切换未确认,当前模式: ${this.currentMode() ?? "unknown"}`); + return false; + } catch (error) { + this.deps.log("error", `切换逐仓模式失败: ${extractMessage(error)}`); + return false; + } finally { + this.ensuring = null; + } + } +} diff --git a/src/strategy/common/token-expiry-guard.ts b/src/strategy/common/token-expiry-guard.ts new file mode 100644 index 0000000..7e062cb --- /dev/null +++ b/src/strategy/common/token-expiry-guard.ts @@ -0,0 +1,137 @@ +import { extractMessage, isUnknownOrderError } from "../../utils/errors"; +import { + checkStandxTokenExpiry, + formatTokenExpiryMessage, + isTokenExpiryConfigured, + type TokenExpiryState, + type TokenExpiryStatus, +} from "../../utils/standx-token-expiry"; +import type { LogHandler } from "./subscriptions"; + +/** What the engine should do for the rest of this tick. */ +export type TokenExpiryDecision = + /** Token is live, or expired but a position still needs managing — keep ticking. */ + | { halt: false; closeOnly: boolean } + /** Expired with nothing left to manage — skip the rest of the tick. */ + | { halt: true; closeOnly: boolean }; + +export interface TokenExpiryGuardDeps { + log: LogHandler; + notify: (notification: { + hasPosition: boolean; + hasOpenOrders: boolean; + state: TokenExpiryState; + }) => void; + /** Cancels every resting order; resolves once the venue has accepted. */ + cancelAllOrders: () => Promise; + /** Called after a successful cancel so the engine can drop its local copy. */ + onOrdersCancelled: () => void; +} + +/** + * Drives the StandX token-expiry episode. + * + * The venue's token expires on a wall clock, and each consequence — the warning + * log, the alert, the cancel-everything sweep — must happen exactly once per + * episode and reset when a fresh token arrives. That is five latches whose only + * correctness property is that they move together, so they live in one class + * rather than loose beside forty other engine fields. + */ +export class TokenExpiryGuard { + private state: TokenExpiryState = "active"; + private logged = false; + private notified = false; + private cancelDone = false; + private closeOnly = false; + + constructor(private readonly deps: TokenExpiryGuardDeps) {} + + /** True once expiry has forced the engine into reduce-only quoting. */ + get closeOnlyMode(): boolean { + return this.closeOnly; + } + + get currentState(): TokenExpiryState { + return this.state; + } + + async evaluate(params: { positionAmt: number; openOrderCount: number }): Promise { + if (!isTokenExpiryConfigured()) { + return { halt: false, closeOnly: false }; + } + + const status = checkStandxTokenExpiry(params); + if (!status.expired) { + this.reset(); + return { halt: false, closeOnly: false }; + } + + const previousState = this.state; + this.state = status.state; + + this.logOnce(status); + this.notifyOnce(status); + await this.cancelOnce(params.openOrderCount); + + if (status.state === "expired_with_position") { + if (!this.closeOnly) { + this.closeOnly = true; + this.deps.log("info", "Token 过期,强制进入平仓模式,仅允许 reduce-only 订单"); + } + return { halt: false, closeOnly: true }; + } + + if (status.state === "silent" && previousState !== "silent") { + this.deps.log("info", "进入静默数据接收模式,不再进行任何交易操作"); + } + return { halt: true, closeOnly: this.closeOnly }; + } + + /** A fresh token clears every latch so the next episode reports itself again. */ + private reset(): void { + if (this.state === "active") return; + this.state = "active"; + this.logged = false; + this.notified = false; + this.cancelDone = false; + this.closeOnly = false; + } + + private logOnce(status: TokenExpiryStatus): void { + if (this.logged) return; + const message = formatTokenExpiryMessage(status); + if (message) { + this.deps.log("warn", message); + } + this.logged = true; + } + + private notifyOnce(status: TokenExpiryStatus): void { + if (this.notified) return; + this.deps.notify({ + hasPosition: status.hasPosition, + hasOpenOrders: status.hasOpenOrders, + state: status.state, + }); + this.notified = true; + } + + private async cancelOnce(openOrderCount: number): Promise { + if (this.cancelDone || openOrderCount === 0) return; + try { + await this.deps.cancelAllOrders(); + this.deps.log("order", "Token 过期,已撤销所有挂单"); + this.deps.onOrdersCancelled(); + this.cancelDone = true; + } catch (error) { + if (isUnknownOrderError(error)) { + // Nothing left to cancel is the outcome we wanted. + this.deps.log("order", "Token 过期撤单时订单已不存在"); + this.cancelDone = true; + return; + } + // Leave cancelDone false so the next tick retries. + this.deps.log("error", `Token 过期撤单失败: ${extractMessage(error)}`); + } + } +} diff --git a/src/strategy/maker-points-engine.ts b/src/strategy/maker-points-engine.ts index fa17c98..50c5919 100644 --- a/src/strategy/maker-points-engine.ts +++ b/src/strategy/maker-points-engine.ts @@ -37,12 +37,8 @@ import { SessionVolumeTracker } from "./common/session-volume"; import { BinanceDepthTracker, type BinanceDepthSnapshot } from "./common/binance-depth"; import { buildBpsTargets } from "./maker-points-logic"; import { t } from "../i18n"; -import { - checkStandxTokenExpiry, - formatTokenExpiryMessage, - isTokenExpiryConfigured, - type TokenExpiryState, -} from "../utils/standx-token-expiry"; +import { IsolatedMarginGuard } from "./common/isolated-margin-guard"; +import { TokenExpiryGuard } from "./common/token-expiry-guard"; import { createTelegramNotifier, type NotificationSender, @@ -102,8 +98,6 @@ const STOP_LOSS_COOLDOWN_MS = 5_000; const STOP_LOSS_CHECK_INTERVAL_MS = 250; // 止损检查最大间隔 const STOP_LOSS_RETRY_INTERVAL_MS = 500; // 止损失败后重试间隔 const DEFENSE_MODE_CHECK_INTERVAL_MS = 1000; // 防御模式检查间隔 -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 { @@ -162,11 +156,7 @@ export class MakerPointsEngine { private insufficientBalanceNotified = false; private lastInsufficientMessage: string | null = null; - private tokenExpiryState: TokenExpiryState = "active"; - private tokenExpiryLogged = false; - private tokenExpiryCancelDone = false; - private tokenExpiredCloseOnlyMode = false; - private tokenExpiryNotified = false; + private readonly tokenExpiry: TokenExpiryGuard; private lastPositionAmt = 0; private lastPositionSide: "LONG" | "SHORT" | "FLAT" = "FLAT"; @@ -195,7 +185,7 @@ export class MakerPointsEngine { private standxRestConsecutiveErrors = 0; private standxRestUnhealthy = false; private standxRestLastError: string | null = null; - private marginModeEnsuring: Promise | null = null; + private readonly marginGuard: IsolatedMarginGuard; constructor(private readonly config: MakerPointsConfig, private readonly exchange: ExchangeAdapter) { this.tradeLog = createTradeLog(this.config.maxLogEntries); @@ -206,6 +196,34 @@ export class MakerPointsEngine { this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) => this.tradeLog.push(type, detail) ); + this.marginGuard = new IsolatedMarginGuard({ + symbol: this.config.symbol, + enabled: this.exchange.id === "standx", + log: (type, detail) => this.tradeLog.push(type, detail), + currentSnapshot: () => this.accountSnapshot, + changeMarginMode: this.exchange.changeMarginMode?.bind(this.exchange), + queryAccountSnapshot: this.exchange.queryAccountSnapshot?.bind(this.exchange), + applySnapshot: (snapshot) => this.applyAccountSnapshot(snapshot), + sleep: (ms) => this.sleep(ms), + }); + this.tokenExpiry = new TokenExpiryGuard({ + log: (type, detail) => this.tradeLog.push(type, detail), + notify: ({ hasPosition, hasOpenOrders, state }) => + this.notify({ + type: "token_expired", + level: "warn", + symbol: this.config.symbol, + title: "Token 已过期", + message: hasPosition + ? "Token 已过期,进入平仓模式,不再开新仓" + : "Token 已过期,策略进入静默模式", + details: { hasPosition, hasOpenOrders, state }, + }), + cancelAllOrders: () => this.exchange.cancelAllOrders({ symbol: this.config.symbol }), + onOrdersCancelled: () => { + this.openOrders = []; + }, + }); this.binanceDepth = new BinanceDepthTracker(resolveBinanceSymbol(this.config.symbol), { baseUrl: process.env.BINANCE_SPOT_WS_URL ?? process.env.BINANCE_WS_URL, restBaseUrl: process.env.BINANCE_REST_URL, @@ -468,7 +486,7 @@ export class MakerPointsEngine { restUnhealthy: true, restConsecutiveErrors: this.standxRestConsecutiveErrors, restLastError: this.standxRestLastError, - marginMode: this.getStandxMarginMode(this.accountSnapshot), + marginMode: this.marginGuard.currentMode(), }) ); } @@ -604,8 +622,8 @@ export class MakerPointsEngine { return; } - if (!(await this.ensureStandxIsolatedMarginMode())) { - const current = this.getStandxMarginMode(this.accountSnapshot); + if (!(await this.marginGuard.ensureIsolated())) { + const current = this.marginGuard.currentMode(); this.enterDefenseMode( defenseReasonsFor({ marginModeNotIsolated: true, @@ -626,7 +644,7 @@ export class MakerPointsEngine { accountIssues: accountHealth.issues, restConsecutiveErrors: this.standxRestConsecutiveErrors, restLastError: this.standxRestLastError, - marginMode: this.getStandxMarginMode(this.accountSnapshot), + marginMode: this.marginGuard.currentMode(), }) ); this.emitUpdate(); @@ -642,7 +660,11 @@ export class MakerPointsEngine { const position = getPosition(this.accountSnapshot, this.config.symbol); const absPosition = Math.abs(position.positionAmt); - if (await this.handleTokenExpiry(position, absPosition)) { + const expiry = await this.tokenExpiry.evaluate({ + positionAmt: position.positionAmt, + openOrderCount: this.openOrders.length, + }); + if (expiry.halt) { this.emitUpdate(); return; } @@ -656,7 +678,7 @@ export class MakerPointsEngine { const closeThreshold = Number(this.config.closeThreshold); const closeOnly = - this.tokenExpiredCloseOnlyMode || + expiry.closeOnly || (Number.isFinite(closeThreshold) && closeThreshold > 0 && absPosition >= closeThreshold - EPS); @@ -1522,7 +1544,7 @@ export class MakerPointsEngine { }); } else if (currentSide === "FLAT" && prevSide !== "FLAT") { const pnl = position.unrealizedProfit; - const closeType = this.tokenExpiredCloseOnlyMode ? "Token过期平仓" : "平仓"; + const closeType = this.tokenExpiry.closeOnlyMode ? "Token过期平仓" : "平仓"; this.notify({ type: "position_closed", level: "success", @@ -1583,90 +1605,6 @@ export class MakerPointsEngine { this.lastPositionSide = currentSide; } - private async handleTokenExpiry(position: PositionSnapshot, _absPosition: number): Promise { - if (!isTokenExpiryConfigured()) { - return false; - } - - const expiryStatus = checkStandxTokenExpiry({ - positionAmt: position.positionAmt, - openOrderCount: this.openOrders.length, - }); - - if (!expiryStatus.expired) { - if (this.tokenExpiryState !== "active") { - this.tokenExpiryState = "active"; - this.tokenExpiryLogged = false; - this.tokenExpiryCancelDone = false; - this.tokenExpiredCloseOnlyMode = false; - this.tokenExpiryNotified = false; - } - return false; - } - - const prevState = this.tokenExpiryState; - this.tokenExpiryState = expiryStatus.state; - - if (!this.tokenExpiryLogged) { - const message = formatTokenExpiryMessage(expiryStatus); - if (message) { - this.tradeLog.push("warn", message); - } - this.tokenExpiryLogged = true; - } - - if (!this.tokenExpiryNotified) { - this.notify({ - type: "token_expired", - level: "warn", - symbol: this.config.symbol, - title: "Token 已过期", - message: expiryStatus.hasPosition - ? "Token 已过期,进入平仓模式,不再开新仓" - : "Token 已过期,策略进入静默模式", - details: { - hasPosition: expiryStatus.hasPosition, - hasOpenOrders: expiryStatus.hasOpenOrders, - state: expiryStatus.state, - }, - }); - this.tokenExpiryNotified = true; - } - - if (!this.tokenExpiryCancelDone && this.openOrders.length > 0) { - try { - await this.exchange.cancelAllOrders({ symbol: this.config.symbol }); - this.tradeLog.push("order", "Token 过期,已撤销所有挂单"); - this.openOrders = []; - this.tokenExpiryCancelDone = true; - } catch (error) { - if (isUnknownOrderError(error)) { - this.tradeLog.push("order", "Token 过期撤单时订单已不存在"); - this.tokenExpiryCancelDone = true; - } else { - this.tradeLog.push("error", `Token 过期撤单失败: ${extractMessage(error)}`); - } - } - } - - if (expiryStatus.state === "expired_with_position") { - if (!this.tokenExpiredCloseOnlyMode) { - this.tokenExpiredCloseOnlyMode = true; - this.tradeLog.push("info", "Token 过期,强制进入平仓模式,仅允许 reduce-only 订单"); - } - return false; - } - - if (expiryStatus.state === "silent") { - if (prevState !== "silent") { - this.tradeLog.push("info", "进入静默数据接收模式,不再进行任何交易操作"); - } - return true; - } - - return true; - } - // ========== 数据过时防御模式方法 ========== /** @@ -1688,7 +1626,7 @@ export class MakerPointsEngine { restUnhealthy: this.standxRestUnhealthy, restConsecutiveErrors: this.standxRestConsecutiveErrors, restLastError: this.standxRestLastError, - marginMode: this.getStandxMarginMode(this.accountSnapshot), + marginMode: this.marginGuard.currentMode(), enforceIsolatedMargin: this.exchange.id === "standx", }); @@ -1821,7 +1759,7 @@ export class MakerPointsEngine { // 防御模式下也尝试修复保证金模式(StandX) if (this.exchange.id === "standx") { - await this.ensureStandxIsolatedMarginMode(); + await this.marginGuard.ensureIsolated(); } // 防御模式下持续通过 REST 刷新挂单,并尽力撤销所有挂单(避免本地状态/WS 丢失导致遗留挂单) @@ -1867,55 +1805,6 @@ export class MakerPointsEngine { void poll(); } - private getStandxMarginMode(snapshot: AccountSnapshot | null): string | null { - if (this.exchange.id !== "standx") return null; - const positions = snapshot?.positions ?? []; - const match = positions.find((pos) => pos.symbol === this.config.symbol); - const raw = (match as any)?.marginType ?? (match as any)?.margin_mode; - const mode = typeof raw === "string" ? raw.trim().toLowerCase() : ""; - return mode ? mode : null; - } - - private async ensureStandxIsolatedMarginMode(): Promise { - if (this.exchange.id !== "standx") return true; - const currentMode = this.getStandxMarginMode(this.accountSnapshot); - if (currentMode === "isolated") return true; - const change = this.exchange.changeMarginMode?.bind(this.exchange); - const queryAccount = this.exchange.queryAccountSnapshot?.bind(this.exchange); - if (!change || !queryAccount) return false; - - if (this.marginModeEnsuring) { - return false; - } - - this.marginModeEnsuring = (async () => { - try { - await change({ symbol: this.config.symbol, marginMode: "isolated" }); - for (let attempt = 0; attempt < STANDX_MARGIN_MODE_MAX_ATTEMPTS; attempt++) { - const next = await queryAccount(); - if (next) { - this.applyAccountSnapshot(next); - } - const mode = this.getStandxMarginMode(this.accountSnapshot); - if (mode === "isolated") { - this.tradeLog.push("info", "已切换为逐仓模式 (isolated),恢复策略运行"); - return true; - } - await this.sleep(STANDX_MARGIN_MODE_CHECK_INTERVAL_MS); - } - this.tradeLog.push("warn", `逐仓模式切换未确认,当前模式: ${this.getStandxMarginMode(this.accountSnapshot) ?? "unknown"}`); - return false; - } catch (error) { - this.tradeLog.push("error", `切换逐仓模式失败: ${extractMessage(error)}`); - return false; - } finally { - this.marginModeEnsuring = null; - } - })(); - - return await this.marginModeEnsuring; - } - /** * 停止防御模式下的 REST 轮询 */ diff --git a/tests/isolated-margin-guard.test.ts b/tests/isolated-margin-guard.test.ts new file mode 100644 index 0000000..b1e5d63 --- /dev/null +++ b/tests/isolated-margin-guard.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from "vitest"; +import { IsolatedMarginGuard } from "../src/strategy/common/isolated-margin-guard"; +import type { AccountSnapshot } from "../src/exchanges/types"; + +const SYMBOL = "BTC-USD"; + +function snapshotWithMode(mode: string | null): AccountSnapshot { + return { + positions: [{ symbol: SYMBOL, ...(mode ? { marginType: mode } : {}) }], + } as unknown as AccountSnapshot; +} + +function makeGuard(options: { + enabled?: boolean; + initialMode?: string | null; + /** Modes the account reports on successive polls. */ + polledModes?: Array; + changeMarginMode?: (params: { symbol: string; marginMode: "isolated" | "cross" }) => Promise; + omitCapabilities?: boolean; +} = {}) { + const logs: Array<[string, string]> = []; + let current = snapshotWithMode("initialMode" in options ? options.initialMode! : "cross"); + const polled = [...(options.polledModes ?? [])]; + const queryAccountSnapshot = vi.fn(async () => snapshotWithMode(polled.shift() ?? "cross")); + const changeMarginMode = vi.fn(options.changeMarginMode ?? (async () => {})); + + const guard = new IsolatedMarginGuard({ + symbol: SYMBOL, + enabled: options.enabled ?? true, + log: (type, detail) => logs.push([type, detail]), + currentSnapshot: () => current, + changeMarginMode: options.omitCapabilities ? undefined : changeMarginMode, + queryAccountSnapshot: options.omitCapabilities ? undefined : queryAccountSnapshot, + applySnapshot: (next) => { + current = next; + }, + // No real waiting in tests. + sleep: async () => {}, + }); + return { guard, logs, changeMarginMode, queryAccountSnapshot }; +} + +describe("IsolatedMarginGuard", () => { + it("is inert on venues without a per-symbol margin mode", async () => { + const { guard, changeMarginMode } = makeGuard({ enabled: false }); + expect(await guard.ensureIsolated()).toBe(true); + expect(guard.currentMode()).toBeNull(); + expect(changeMarginMode).not.toHaveBeenCalled(); + }); + + it("does nothing when already isolated", async () => { + const { guard, changeMarginMode } = makeGuard({ initialMode: "isolated" }); + expect(await guard.ensureIsolated()).toBe(true); + expect(changeMarginMode).not.toHaveBeenCalled(); + }); + + it("normalises the reported mode", async () => { + const { guard } = makeGuard({ initialMode: " ISOLATED " }); + expect(guard.currentMode()).toBe("isolated"); + }); + + it("reports an unknown mode as null", async () => { + const { guard } = makeGuard({ initialMode: null }); + expect(guard.currentMode()).toBeNull(); + }); + + it("switches and confirms through a snapshot poll", async () => { + const { guard, logs, changeMarginMode } = makeGuard({ + initialMode: "cross", + polledModes: ["cross", "isolated"], + }); + expect(await guard.ensureIsolated()).toBe(true); + expect(changeMarginMode).toHaveBeenCalledWith({ symbol: SYMBOL, marginMode: "isolated" }); + expect(logs.some(([, detail]) => detail.includes("已切换为逐仓模式"))).toBe(true); + }); + + it("gives up after the confirm attempts run out", async () => { + const { guard, logs, queryAccountSnapshot } = makeGuard({ polledModes: [] }); + expect(await guard.ensureIsolated()).toBe(false); + expect(queryAccountSnapshot).toHaveBeenCalledTimes(10); + expect(logs.some(([type]) => type === "warn")).toBe(true); + }); + + it("reports failure when the venue rejects the change", async () => { + const { guard, logs } = makeGuard({ + changeMarginMode: async () => { + throw new Error("rejected"); + }, + }); + expect(await guard.ensureIsolated()).toBe(false); + expect(logs.some(([type]) => type === "error")).toBe(true); + }); + + it("returns false when the adapter cannot change margin mode", async () => { + const { guard } = makeGuard({ omitCapabilities: true }); + expect(await guard.ensureIsolated()).toBe(false); + }); + + it("shares one in-flight switch across concurrent ticks", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { guard, changeMarginMode } = makeGuard({ + polledModes: ["isolated"], + changeMarginMode: async () => { + await gate; + }, + }); + + const first = guard.ensureIsolated(); + // A tick arriving mid-switch must not fire a second change request. + const second = await guard.ensureIsolated(); + expect(second).toBe(false); + release(); + expect(await first).toBe(true); + expect(changeMarginMode).toHaveBeenCalledTimes(1); + }); + + it("allows a fresh attempt after the previous one settles", async () => { + const { guard, changeMarginMode } = makeGuard({ polledModes: [] }); + expect(await guard.ensureIsolated()).toBe(false); + expect(await guard.ensureIsolated()).toBe(false); + expect(changeMarginMode).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/token-expiry-guard.test.ts b/tests/token-expiry-guard.test.ts new file mode 100644 index 0000000..bcc2b3c --- /dev/null +++ b/tests/token-expiry-guard.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { standxTokenConfig } from "../src/config"; +import { TokenExpiryGuard } from "../src/strategy/common/token-expiry-guard"; + +const HOUR_MS = 3_600_000; +const original = standxTokenConfig.expiryTimestamp; + +/** The config field is read on every call, so tests set it directly. */ +function setExpiry(atMs: number | null): void { + standxTokenConfig.expiryTimestamp = atMs; +} + +function makeGuard() { + const logs: Array<[string, string]> = []; + const notifications: unknown[] = []; + const cancelAllOrders = vi.fn(async () => {}); + const onOrdersCancelled = vi.fn(); + const guard = new TokenExpiryGuard({ + log: (type, detail) => logs.push([type, detail]), + notify: (n) => notifications.push(n), + cancelAllOrders, + onOrdersCancelled, + }); + return { guard, logs, notifications, cancelAllOrders, onOrdersCancelled }; +} + +describe("TokenExpiryGuard", () => { + afterEach(() => { + standxTokenConfig.expiryTimestamp = original; + }); + + it("stays out of the way when no expiry is configured", async () => { + setExpiry(null); + const { guard, cancelAllOrders } = makeGuard(); + const decision = await guard.evaluate({ positionAmt: 1, openOrderCount: 3 }); + expect(decision).toEqual({ halt: false, closeOnly: false }); + expect(cancelAllOrders).not.toHaveBeenCalled(); + }); + + it("does nothing while the token is still valid", async () => { + setExpiry(Date.now() + HOUR_MS * 24); + const { guard, cancelAllOrders, notifications } = makeGuard(); + const decision = await guard.evaluate({ positionAmt: 0, openOrderCount: 0 }); + expect(decision).toEqual({ halt: false, closeOnly: false }); + expect(cancelAllOrders).not.toHaveBeenCalled(); + expect(notifications).toHaveLength(0); + }); + + it("cancels once and keeps ticking while a position is still open", async () => { + setExpiry(Date.now() - HOUR_MS); + const { guard, cancelAllOrders, onOrdersCancelled } = makeGuard(); + + const first = await guard.evaluate({ positionAmt: 2, openOrderCount: 4 }); + expect(first).toEqual({ halt: false, closeOnly: true }); + expect(cancelAllOrders).toHaveBeenCalledTimes(1); + expect(onOrdersCancelled).toHaveBeenCalledTimes(1); + + await guard.evaluate({ positionAmt: 2, openOrderCount: 4 }); + expect(cancelAllOrders).toHaveBeenCalledTimes(1); + }); + + it("logs and notifies exactly once per episode", async () => { + setExpiry(Date.now() - HOUR_MS); + const { guard, logs, notifications } = makeGuard(); + + await guard.evaluate({ positionAmt: 2, openOrderCount: 1 }); + await guard.evaluate({ positionAmt: 2, openOrderCount: 1 }); + await guard.evaluate({ positionAmt: 2, openOrderCount: 1 }); + + expect(notifications).toHaveLength(1); + expect(logs.filter(([type]) => type === "warn")).toHaveLength(1); + }); + + it("halts the tick once nothing is left to manage", async () => { + setExpiry(Date.now() - HOUR_MS); + const { guard } = makeGuard(); + expect((await guard.evaluate({ positionAmt: 0, openOrderCount: 0 })).halt).toBe(true); + }); + + it("announces the silent mode only on entry", async () => { + setExpiry(Date.now() - HOUR_MS); + const { guard, logs } = makeGuard(); + await guard.evaluate({ positionAmt: 0, openOrderCount: 0 }); + await guard.evaluate({ positionAmt: 0, openOrderCount: 0 }); + const entryLogs = logs.filter( + ([type, detail]) => type === "info" && detail.includes("静默数据接收模式") + ); + expect(entryLogs).toHaveLength(1); + }); + + it("retries the cancel on the next tick when it fails", async () => { + setExpiry(Date.now() - HOUR_MS); + const { guard, cancelAllOrders, logs } = makeGuard(); + cancelAllOrders.mockRejectedValueOnce(new Error("network down")); + + await guard.evaluate({ positionAmt: 1, openOrderCount: 2 }); + expect(logs.some(([type]) => type === "error")).toBe(true); + + await guard.evaluate({ positionAmt: 1, openOrderCount: 2 }); + expect(cancelAllOrders).toHaveBeenCalledTimes(2); + }); + + it("treats an already-gone order as a successful cancel", async () => { + setExpiry(Date.now() - HOUR_MS); + const { guard, cancelAllOrders } = makeGuard(); + cancelAllOrders.mockRejectedValueOnce(new Error("Unknown order sent.")); + + await guard.evaluate({ positionAmt: 1, openOrderCount: 2 }); + await guard.evaluate({ positionAmt: 1, openOrderCount: 2 }); + expect(cancelAllOrders).toHaveBeenCalledTimes(1); + }); + + it("skips the cancel when there is nothing resting", async () => { + setExpiry(Date.now() - HOUR_MS); + const { guard, cancelAllOrders } = makeGuard(); + await guard.evaluate({ positionAmt: 1, openOrderCount: 0 }); + expect(cancelAllOrders).not.toHaveBeenCalled(); + }); + + it("exposes closeOnlyMode for the engine's close-reason label", async () => { + setExpiry(Date.now() - HOUR_MS); + const { guard } = makeGuard(); + expect(guard.closeOnlyMode).toBe(false); + await guard.evaluate({ positionAmt: 3, openOrderCount: 0 }); + expect(guard.closeOnlyMode).toBe(true); + }); + + it("re-arms every latch once a fresh token arrives", async () => { + // The five latches must reset together; a stale one would silently suppress + // the log, alert, or cancel for the next expiry. + setExpiry(Date.now() - HOUR_MS); + const { guard, notifications, cancelAllOrders, logs } = makeGuard(); + + await guard.evaluate({ positionAmt: 5, openOrderCount: 1 }); + expect(guard.closeOnlyMode).toBe(true); + expect(notifications).toHaveLength(1); + + setExpiry(Date.now() + HOUR_MS * 24); + await guard.evaluate({ positionAmt: 5, openOrderCount: 1 }); + expect(guard.closeOnlyMode).toBe(false); + expect(guard.currentState).toBe("active"); + + setExpiry(Date.now() - HOUR_MS); + await guard.evaluate({ positionAmt: 5, openOrderCount: 1 }); + expect(notifications).toHaveLength(2); + expect(cancelAllOrders).toHaveBeenCalledTimes(2); + expect(logs.filter(([type]) => type === "warn")).toHaveLength(2); + }); +});