mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
refactor(maker-points): extract token-expiry and isolated-margin guards
Two more clusters lifted out of the engine, both defined by latches whose only correctness property is that they move together: - TokenExpiryGuard owns the five flags (state, logged, notified, cancelDone, closeOnly) that make each consequence of an expired StandX token happen once per episode and re-arm when a fresh token arrives. evaluate() returns a decision instead of a bare boolean, so the tick reads what it means. - IsolatedMarginGuard owns the single in-flight switch promise that stops concurrent ticks from stacking margin-mode change requests, plus the poll-until-confirmed loop. Both were previously reachable only through a live adapter; they now have 22 unit tests between them, covering the latch reset across a token renewal, the cancel retry after a failure, unknown-order treated as success, and the concurrent-tick sharing of one margin switch. Confirm cadence kept at 500ms x 10 to match the engine's original constants. 271 pass; tsc and oxlint clean. Engine 1939 -> 1826 lines.
This commit is contained in:
@@ -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<void>;
|
||||
queryAccountSnapshot?: () => Promise<AccountSnapshot | null>;
|
||||
/** Feeds a freshly polled snapshot back into the engine before re-reading the mode. */
|
||||
applySnapshot: (snapshot: AccountSnapshot) => void;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> | 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<boolean> {
|
||||
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<IsolatedMarginGuardDeps["changeMarginMode"]>,
|
||||
queryAccountSnapshot: NonNullable<IsolatedMarginGuardDeps["queryAccountSnapshot"]>
|
||||
): Promise<boolean> {
|
||||
const sleep = this.deps.sleep ?? ((ms: number) => new Promise<void>((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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
/** 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<TokenExpiryDecision> {
|
||||
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<void> {
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> | 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<boolean> {
|
||||
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<boolean> {
|
||||
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 轮询
|
||||
*/
|
||||
|
||||
@@ -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<string | null>;
|
||||
changeMarginMode?: (params: { symbol: string; marginMode: "isolated" | "cross" }) => Promise<void>;
|
||||
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<void>((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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user