diff --git a/src/exchanges/adapter.ts b/src/exchanges/adapter.ts index 08b0b40..aa127b3 100644 --- a/src/exchanges/adapter.ts +++ b/src/exchanges/adapter.ts @@ -84,5 +84,6 @@ export interface ExchangeAdapter { offRestHealthEvent?(listener: RestHealthListener): void; queryOpenOrders?(): Promise; queryAccountSnapshot?(): Promise; + changeMarginMode?(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise; forceCancelAllOrders?(): Promise; } diff --git a/src/exchanges/standx/adapter.ts b/src/exchanges/standx/adapter.ts index 9b1109d..2f2042d 100644 --- a/src/exchanges/standx/adapter.ts +++ b/src/exchanges/standx/adapter.ts @@ -161,6 +161,11 @@ export class StandxExchangeAdapter implements ExchangeAdapter { return this.gateway.queryAccountSnapshot(); } + async changeMarginMode(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise { + await this.ensureInitialized("changeMarginMode"); + await this.gateway.changeMarginMode(params.symbol, params.marginMode); + } + /** * 强制取消所有挂单 * 会查询当前挂单然后取消,并验证取消成功 diff --git a/src/exchanges/standx/gateway.ts b/src/exchanges/standx/gateway.ts index 91bfdac..87ac2f1 100644 --- a/src/exchanges/standx/gateway.ts +++ b/src/exchanges/standx/gateway.ts @@ -1462,6 +1462,30 @@ export class StandxGateway { return await this.refreshAccountSnapshot(); } + async changeMarginMode(symbol: string, marginMode: "isolated" | "cross"): Promise { + if (!this.signer.hasKey()) { + throw new Error("StandX change_margin_mode requires STANDX_REQUEST_PRIVATE_KEY for signed requests"); + } + const normalized = normalizeSymbol(symbol); + const response = await this.requestJson<{ code?: number; message?: string; request_id?: string }>( + "/api/change_margin_mode", + { + method: "POST", + body: { + symbol: normalized, + margin_mode: marginMode, + }, + signed: true, + extraHeaders: { + "x-session-id": this.sessionId, + }, + } + ); + if (response && typeof response.code === "number" && response.code !== 0) { + throw new Error(response.message ?? "StandX change margin mode rejected"); + } + } + private async refreshOpenOrders(symbol: string): Promise { try { const ordersPayload = await this.requestJson("/api/query_open_orders", { diff --git a/src/strategy/maker-points-engine.ts b/src/strategy/maker-points-engine.ts index 22757cc..2729fe3 100644 --- a/src/strategy/maker-points-engine.ts +++ b/src/strategy/maker-points-engine.ts @@ -97,6 +97,8 @@ 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 STANDX_REST_ERROR_DEFENSE_THRESHOLD = 3; +const STANDX_MARGIN_MODE_CHECK_INTERVAL_MS = 500; +const STANDX_MARGIN_MODE_MAX_ATTEMPTS = 10; export class MakerPointsEngine { private accountSnapshot: AsterAccountSnapshot | null = null; @@ -185,6 +187,7 @@ export class MakerPointsEngine { private standxRestConsecutiveErrors = 0; private standxRestUnhealthy = false; private standxRestLastError: string | null = null; + private marginModeEnsuring: Promise | null = null; constructor(private readonly config: MakerPointsConfig, private readonly exchange: ExchangeAdapter) { this.tradeLog = createTradeLog(this.config.maxLogEntries); @@ -399,6 +402,8 @@ export class MakerPointsEngine { standxRestUnhealthy: true, standxRestConsecutiveErrors: this.standxRestConsecutiveErrors, standxRestLastError: this.standxRestLastError, + marginModeNotIsolated: false, + marginMode: this.getStandxMarginMode(this.accountSnapshot), standxDepthAge: 0, binanceAge: 0, standxAccountAge: 0, @@ -535,6 +540,27 @@ export class MakerPointsEngine { return; } + if (!(await this.ensureStandxIsolatedMarginMode())) { + const current = this.getStandxMarginMode(this.accountSnapshot); + this.enterDefenseMode({ + standxDepthStale: false, + binanceStale: false, + standxAccountStale: false, + accountInvalid: false, + standxRestUnhealthy: false, + standxRestConsecutiveErrors: this.standxRestConsecutiveErrors, + standxRestLastError: this.standxRestLastError, + marginModeNotIsolated: true, + marginMode: current, + standxDepthAge: 0, + binanceAge: 0, + standxAccountAge: 0, + accountIssues: [], + }); + this.emitUpdate(); + return; + } + const accountHealth = validateAccountSnapshotForSymbol(this.accountSnapshot, this.config.symbol); if (!accountHealth.ok && !this.defenseMode) { this.enterDefenseMode({ @@ -545,6 +571,8 @@ export class MakerPointsEngine { standxRestUnhealthy: false, standxRestConsecutiveErrors: this.standxRestConsecutiveErrors, standxRestLastError: this.standxRestLastError, + marginModeNotIsolated: false, + marginMode: this.getStandxMarginMode(this.accountSnapshot), standxDepthAge: 0, binanceAge: 0, standxAccountAge: 0, @@ -1594,8 +1622,16 @@ export class MakerPointsEngine { const accountInvalid = this.accountSnapshot != null && !accountHealth.ok; const standxRestUnhealthy = this.standxRestUnhealthy && this.standxRestConsecutiveErrors >= STANDX_REST_ERROR_DEFENSE_THRESHOLD; + const marginMode = this.getStandxMarginMode(this.accountSnapshot); + const marginModeNotIsolated = this.exchange.id === "standx" && marginMode != null && marginMode !== "isolated"; - const shouldDefend = standxDepthStale || binanceStale || standxAccountStale || accountInvalid || standxRestUnhealthy; + const shouldDefend = + standxDepthStale || + binanceStale || + standxAccountStale || + accountInvalid || + standxRestUnhealthy || + marginModeNotIsolated; if (shouldDefend && !this.defenseMode) { // 进入防御模式 @@ -1607,6 +1643,8 @@ export class MakerPointsEngine { standxRestUnhealthy, standxRestConsecutiveErrors: this.standxRestConsecutiveErrors, standxRestLastError: this.standxRestLastError, + marginModeNotIsolated, + 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, @@ -1630,6 +1668,8 @@ export class MakerPointsEngine { standxRestUnhealthy: boolean; standxRestConsecutiveErrors: number; standxRestLastError: string | null; + marginModeNotIsolated: boolean; + marginMode: string | null; standxDepthAge: number; binanceAge: number; standxAccountAge: number; @@ -1651,6 +1691,9 @@ export class MakerPointsEngine { if (staleInfo.standxRestUnhealthy) { staleItems.push(`StandX REST错误(${staleInfo.standxRestConsecutiveErrors}次)`); } + if (staleInfo.marginModeNotIsolated) { + staleItems.push(`保证金模式(${staleInfo.marginMode ?? "unknown"})`); + } if (staleInfo.binanceStale) { staleItems.push(`Binance深度(${Math.round(staleInfo.binanceAge / 1000)}s)`); } @@ -1766,6 +1809,11 @@ export class MakerPointsEngine { } } + // 防御模式下也尝试修复保证金模式(StandX) + if (this.exchange.id === "standx") { + await this.ensureStandxIsolatedMarginMode(); + } + // 防御模式下持续通过 REST 刷新挂单,并尽力撤销所有挂单(避免本地状态/WS 丢失导致遗留挂单) if (this.exchange.queryOpenOrders) { try { @@ -1809,6 +1857,57 @@ export class MakerPointsEngine { void poll(); } + private getStandxMarginMode(snapshot: AsterAccountSnapshot | 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.accountSnapshot = next; + this.lastStandxAccountTime = Date.now(); + this.feedStatus.account = true; + } + 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/maker-points-defense-rest.test.ts b/tests/maker-points-defense-rest.test.ts index 8c825b1..044a3c4 100644 --- a/tests/maker-points-defense-rest.test.ts +++ b/tests/maker-points-defense-rest.test.ts @@ -99,6 +99,8 @@ describe("MakerPointsEngine defense-mode REST polling", () => { standxRestUnhealthy: false, standxRestConsecutiveErrors: 0, standxRestLastError: null, + marginModeNotIsolated: false, + marginMode: "isolated", standxDepthAge: 6000, binanceAge: 0, standxAccountAge: 0, @@ -149,6 +151,8 @@ describe("MakerPointsEngine defense-mode REST polling", () => { standxRestUnhealthy: false, standxRestConsecutiveErrors: 0, standxRestLastError: null, + marginModeNotIsolated: false, + marginMode: "isolated", standxDepthAge: 6000, binanceAge: 0, standxAccountAge: 0, @@ -162,4 +166,3 @@ describe("MakerPointsEngine defense-mode REST polling", () => { engine.stop(); }); }); - diff --git a/tests/maker-points-margin-mode.test.ts b/tests/maker-points-margin-mode.test.ts new file mode 100644 index 0000000..f15ed69 --- /dev/null +++ b/tests/maker-points-margin-mode.test.ts @@ -0,0 +1,188 @@ +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 StandxStubAdapter implements ExchangeAdapter { + id = "standx"; + marginMode: "cross" | "isolated" = "cross"; + changeCalls: Array<{ symbol: string; marginMode: "isolated" | "cross" }> = []; + + 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 { + canTrade: true, + canDeposit: true, + canWithdraw: true, + updateTime: Date.now(), + totalWalletBalance: "0", + totalUnrealizedProfit: "0", + marketType: "perp", + positions: [ + { + symbol: "BTC-USD", + positionAmt: "0", + entryPrice: "0", + unrealizedProfit: "0", + positionSide: "BOTH", + updateTime: Date.now(), + marginType: this.marginMode, + }, + ], + assets: [], + }; + } + + async changeMarginMode(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise { + this.changeCalls.push(params); + this.marginMode = params.marginMode; + } +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("MakerPointsEngine StandX isolated margin guard", () => { + it("switches to isolated before placing orders", async () => { + vi.useFakeTimers(); + const adapter = new StandxStubAdapter(); + + 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 + ); + + // Seed engine state to pass readiness checks without WS. + (engine as any).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true }; + (engine as any).initialOrderSnapshotReady = true; + (engine as any).accountSnapshot = await adapter.queryAccountSnapshot(); + (engine as any).depthSnapshot = { + lastUpdateId: 1, + bids: [["100", "1"]], + asks: [["101", "1"]], + eventTime: Date.now(), + symbol: "BTC-USD", + } as AsterDepth; + (engine as any).tickerSnapshot = { + symbol: "BTC-USD", + lastPrice: "100", + openPrice: "0", + highPrice: "0", + lowPrice: "0", + volume: "0", + quoteVolume: "0", + eventTime: Date.now(), + } as AsterTicker; + + const syncSpy = vi.fn().mockResolvedValue(undefined); + (engine as any).syncOrders = syncSpy; + + // First tick should force margin mode to isolated and then proceed to sync orders. + await (engine as any).tick(); + + expect(adapter.changeCalls).toEqual([{ symbol: "BTC-USD", marginMode: "isolated" }]); + expect(syncSpy).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it("enters defense mode if it cannot switch to isolated", async () => { + vi.useFakeTimers(); + const adapter = new StandxStubAdapter(); + adapter.changeMarginMode = vi.fn(async () => { + throw new Error("change failed"); + }) as any; + + 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 + ); + + (engine as any).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true }; + (engine as any).initialOrderSnapshotReady = true; + (engine as any).accountSnapshot = await adapter.queryAccountSnapshot(); + (engine as any).depthSnapshot = { + lastUpdateId: 1, + bids: [["100", "1"]], + asks: [["101", "1"]], + eventTime: Date.now(), + symbol: "BTC-USD", + } as AsterDepth; + (engine as any).tickerSnapshot = { + symbol: "BTC-USD", + lastPrice: "100", + openPrice: "0", + highPrice: "0", + lowPrice: "0", + volume: "0", + quoteVolume: "0", + eventTime: Date.now(), + } as AsterTicker; + + const syncSpy = vi.fn().mockResolvedValue(undefined); + (engine as any).syncOrders = syncSpy; + + await (engine as any).tick(); + + expect(syncSpy).not.toHaveBeenCalled(); + expect((engine as any).defenseMode).toBe(true); + + engine.stop(); + }); +}); +