diff --git a/src/strategy/maker-points-defense.test.ts b/src/strategy/maker-points-defense.test.ts new file mode 100644 index 0000000..d395b53 --- /dev/null +++ b/src/strategy/maker-points-defense.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { + ACCOUNT_DATA_STALE_THRESHOLD_MS, + DATA_STALE_THRESHOLD_MS, + REST_ERROR_DEFENSE_THRESHOLD, + defenseReasonsFor, + describeDefenseReasons, + evaluateDefense, + type DefenseInputs, +} from "./maker-points-defense"; + +const NOW = 1_700_000_000_000; + +function inputs(overrides: Partial = {}): DefenseInputs { + return { + now: NOW, + lastDepthTime: NOW, + lastAccountTime: NOW, + lastBinanceDepthTime: NOW, + binanceHealth: { healthy: true }, + accountHealth: { ok: true }, + hasAccountSnapshot: true, + accountProbeFailures: 0, + accountProbeInFlight: false, + restUnhealthy: false, + restConsecutiveErrors: 0, + restLastError: null, + marginMode: "isolated", + enforceIsolatedMargin: true, + ...overrides, + }; +} + +describe("evaluateDefense", () => { + it("stays out of defense when every feed is fresh", () => { + expect(evaluateDefense(inputs()).shouldDefend).toBe(false); + }); + + it("treats a feed that has never reported as fresh, not stale", () => { + // Startup: age 0 must not be read as "infinitely old". + const verdict = evaluateDefense( + inputs({ lastDepthTime: 0, lastAccountTime: 0, lastBinanceDepthTime: 0 }) + ); + expect(verdict.shouldDefend).toBe(false); + expect(verdict.reasons.depthAge).toBe(0); + }); + + it("defends on a stale venue depth feed", () => { + const verdict = evaluateDefense( + inputs({ lastDepthTime: NOW - DATA_STALE_THRESHOLD_MS - 1 }) + ); + expect(verdict.shouldDefend).toBe(true); + expect(verdict.reasons.depthStale).toBe(true); + }); + + it("does not defend exactly at the staleness threshold", () => { + expect(evaluateDefense(inputs({ lastDepthTime: NOW - DATA_STALE_THRESHOLD_MS })).shouldDefend).toBe( + false + ); + }); + + it("defends on a stale Binance depth feed or an unhealthy book", () => { + expect( + evaluateDefense(inputs({ lastBinanceDepthTime: NOW - DATA_STALE_THRESHOLD_MS - 1 })).shouldDefend + ).toBe(true); + expect( + evaluateDefense(inputs({ binanceHealth: { healthy: false, reason: "gap" } })).shouldDefend + ).toBe(true); + }); + + it("probes but does not defend when the account feed first goes quiet", () => { + const verdict = evaluateDefense( + inputs({ lastAccountTime: NOW - ACCOUNT_DATA_STALE_THRESHOLD_MS - 1 }) + ); + expect(verdict.needsAccountProbe).toBe(true); + expect(verdict.shouldDefend).toBe(false); + }); + + it("holds off while the account REST probe is still in flight", () => { + const verdict = evaluateDefense( + inputs({ + lastAccountTime: NOW - ACCOUNT_DATA_STALE_THRESHOLD_MS - 1, + accountProbeFailures: 2, + accountProbeInFlight: true, + }) + ); + expect(verdict.shouldDefend).toBe(false); + }); + + it("defends once the account REST probe has failed", () => { + const verdict = evaluateDefense( + inputs({ + lastAccountTime: NOW - ACCOUNT_DATA_STALE_THRESHOLD_MS - 1, + accountProbeFailures: 1, + accountProbeInFlight: false, + }) + ); + expect(verdict.shouldDefend).toBe(true); + expect(verdict.reasons.accountStale).toBe(true); + }); + + it("defends on an invalid account snapshot and carries its issues", () => { + const verdict = evaluateDefense( + inputs({ accountHealth: { ok: false, issues: ["missing position"] } }) + ); + expect(verdict.shouldDefend).toBe(true); + expect(verdict.reasons.accountIssues).toEqual(["missing position"]); + }); + + it("ignores account validity before any snapshot has arrived", () => { + const verdict = evaluateDefense( + inputs({ hasAccountSnapshot: false, accountHealth: { ok: false, issues: ["x"] } }) + ); + expect(verdict.shouldDefend).toBe(false); + }); + + it("defends only after REST failures reach the threshold", () => { + const below = evaluateDefense( + inputs({ restUnhealthy: true, restConsecutiveErrors: REST_ERROR_DEFENSE_THRESHOLD - 1 }) + ); + expect(below.shouldDefend).toBe(false); + + const at = evaluateDefense( + inputs({ restUnhealthy: true, restConsecutiveErrors: REST_ERROR_DEFENSE_THRESHOLD }) + ); + expect(at.shouldDefend).toBe(true); + expect(at.reasons.restUnhealthy).toBe(true); + }); + + it("defends on a non-isolated margin mode only where it is enforced", () => { + expect(evaluateDefense(inputs({ marginMode: "cross" })).shouldDefend).toBe(true); + expect( + evaluateDefense(inputs({ marginMode: "cross", enforceIsolatedMargin: false })).shouldDefend + ).toBe(false); + }); + + it("does not defend on an unknown margin mode", () => { + expect(evaluateDefense(inputs({ marginMode: null })).shouldDefend).toBe(false); + }); +}); + +describe("describeDefenseReasons", () => { + it("names every active cause", () => { + const summary = describeDefenseReasons( + defenseReasonsFor({ + depthStale: true, + depthAge: 7_000, + restUnhealthy: true, + restConsecutiveErrors: 4, + }) + ); + expect(summary).toContain("StandX深度(7s)"); + expect(summary).toContain("StandX REST错误(4次)"); + }); + + it("falls back to unknown when nothing is flagged", () => { + expect(describeDefenseReasons(defenseReasonsFor({}))).toBe("unknown"); + }); + + it("omits the Binance book reason when there is none", () => { + const summary = describeDefenseReasons( + defenseReasonsFor({ binanceUnhealthy: true, binanceHealthReason: null }) + ); + expect(summary).toBe("unknown"); + }); +}); diff --git a/src/strategy/maker-points-defense.ts b/src/strategy/maker-points-defense.ts new file mode 100644 index 0000000..29b8d7f --- /dev/null +++ b/src/strategy/maker-points-defense.ts @@ -0,0 +1,180 @@ +/** + * Defense-mode decision logic for the Maker Points engine. + * + * Pure by design (mirrors maker-points-logic.ts / grid-logic.ts): it reads a + * snapshot of feed ages and health flags and returns a verdict. Acting on the + * verdict — cancelling orders, starting REST polling — stays in the engine, + * so the rule that decides "is our market data trustworthy" can be tested + * without a live adapter. + */ + +/** A feed older than this is considered stale. */ +export const DATA_STALE_THRESHOLD_MS = 5_000; +/** + * Account pushes can legitimately be sparse, so age alone does not trigger + * defense — it only triggers a REST probe. Defense follows a failed probe. + */ +export const ACCOUNT_DATA_STALE_THRESHOLD_MS = 20_000; +/** Consecutive REST failures before the venue is treated as down. */ +export const REST_ERROR_DEFENSE_THRESHOLD = 3; + +export interface DefenseInputs { + now: number; + /** Epoch ms of the last venue depth update; 0 when none has arrived yet. */ + lastDepthTime: number; + /** Epoch ms of the last venue account update; 0 when none has arrived yet. */ + lastAccountTime: number; + /** Epoch ms of the last Binance depth update; 0 when none has arrived yet. */ + lastBinanceDepthTime: number; + binanceHealth: { healthy: boolean; reason?: string | null }; + /** Result of validating the current account snapshot for the traded symbol. */ + accountHealth: { ok: boolean; issues?: string[] }; + hasAccountSnapshot: boolean; + /** Consecutive failures of the REST fallback that refreshes a stale account. */ + accountProbeFailures: number; + accountProbeInFlight: boolean; + restUnhealthy: boolean; + restConsecutiveErrors: number; + restLastError: string | null; + /** Current margin mode as the venue reports it, or null when unknown. */ + marginMode: string | null; + /** Margin mode is only enforced on StandX. */ + enforceIsolatedMargin: boolean; +} + +/** + * Why defense mode was entered; carried into the log line and the notification. + * A type alias rather than an interface so it satisfies the notification + * payload's index signature without a cast. + */ +export type DefenseReasons = { + depthStale: boolean; + binanceStale: boolean; + binanceUnhealthy: boolean; + binanceHealthReason: string | null; + accountStale: boolean; + accountInvalid: boolean; + restUnhealthy: boolean; + restConsecutiveErrors: number; + restLastError: string | null; + marginModeNotIsolated: boolean; + marginMode: string | null; + depthAge: number; + binanceAge: number; + accountAge: number; + accountIssues: string[]; +}; + +export interface DefenseVerdict { + shouldDefend: boolean; + /** The account feed is old enough that the engine should refresh it over REST. */ + needsAccountProbe: boolean; + reasons: DefenseReasons; +} + +/** Age of a feed that has produced at least one update; 0 for one that has not. */ +function feedAge(now: number, lastUpdate: number): number { + return lastUpdate > 0 ? now - lastUpdate : 0; +} + +function isStale(now: number, lastUpdate: number, threshold: number): boolean { + return lastUpdate > 0 && now - lastUpdate > threshold; +} + +export function evaluateDefense(inputs: DefenseInputs): DefenseVerdict { + const { now } = inputs; + + const depthStale = isStale(now, inputs.lastDepthTime, DATA_STALE_THRESHOLD_MS); + const binanceStale = isStale(now, inputs.lastBinanceDepthTime, DATA_STALE_THRESHOLD_MS); + const binanceUnhealthy = !inputs.binanceHealth.healthy; + + const accountAge = feedAge(now, inputs.lastAccountTime); + const accountStaleByAge = isStale(now, inputs.lastAccountTime, ACCOUNT_DATA_STALE_THRESHOLD_MS); + // Defense waits for the REST fallback to have been tried and failed. + const accountStale = + accountStaleByAge && inputs.accountProbeFailures > 0 && !inputs.accountProbeInFlight; + + const accountInvalid = inputs.hasAccountSnapshot && !inputs.accountHealth.ok; + const restUnhealthy = + inputs.restUnhealthy && inputs.restConsecutiveErrors >= REST_ERROR_DEFENSE_THRESHOLD; + const marginModeNotIsolated = + inputs.enforceIsolatedMargin && inputs.marginMode != null && inputs.marginMode !== "isolated"; + + const shouldDefend = + depthStale || + binanceStale || + binanceUnhealthy || + accountStale || + accountInvalid || + restUnhealthy || + marginModeNotIsolated; + + return { + shouldDefend, + needsAccountProbe: accountStaleByAge, + reasons: { + depthStale, + binanceStale, + binanceUnhealthy, + binanceHealthReason: inputs.binanceHealth.reason ?? null, + accountStale, + accountInvalid, + restUnhealthy, + restConsecutiveErrors: inputs.restConsecutiveErrors, + restLastError: inputs.restLastError, + marginModeNotIsolated, + marginMode: inputs.marginMode, + depthAge: feedAge(now, inputs.lastDepthTime), + binanceAge: feedAge(now, inputs.lastBinanceDepthTime), + accountAge, + accountIssues: accountInvalid ? inputs.accountHealth.issues ?? [] : [], + }, + }; +} + +/** Nothing wrong; the baseline every single-cause reason set starts from. */ +const NO_REASONS: DefenseReasons = { + depthStale: false, + binanceStale: false, + binanceUnhealthy: false, + binanceHealthReason: null, + accountStale: false, + accountInvalid: false, + restUnhealthy: false, + restConsecutiveErrors: 0, + restLastError: null, + marginModeNotIsolated: false, + marginMode: null, + depthAge: 0, + binanceAge: 0, + accountAge: 0, + accountIssues: [], +}; + +/** + * Reason set for a defense trigger that fires outside the periodic check — a REST + * health event, a rejected margin mode — where only one or two causes are known. + */ +export function defenseReasonsFor(known: Partial): DefenseReasons { + return { ...NO_REASONS, ...known }; +} + +/** Human-readable summary of what went stale, for the log and the alert. */ +export function describeDefenseReasons(reasons: DefenseReasons): string { + const items: string[] = []; + const seconds = (ms: number) => Math.round(ms / 1000); + + if (reasons.depthStale) items.push(`StandX深度(${seconds(reasons.depthAge)}s)`); + if (reasons.accountStale) items.push(`StandX账户(${seconds(reasons.accountAge)}s)`); + if (reasons.accountInvalid) { + items.push(`StandX仓位数据异常(${reasons.accountIssues.join(",") || "unknown"})`); + } + if (reasons.restUnhealthy) items.push(`StandX REST错误(${reasons.restConsecutiveErrors}次)`); + if (reasons.marginModeNotIsolated) items.push(`保证金模式(${reasons.marginMode ?? "unknown"})`); + if (reasons.binanceStale) items.push(`Binance深度(${seconds(reasons.binanceAge)}s)`); + if (reasons.binanceUnhealthy && reasons.binanceHealthReason) { + items.push(`Binance簿记异常(${reasons.binanceHealthReason})`); + } + + return items.length > 0 ? items.join(", ") : "unknown"; +} diff --git a/src/strategy/maker-points-engine.ts b/src/strategy/maker-points-engine.ts index 343894d..35f838c 100644 --- a/src/strategy/maker-points-engine.ts +++ b/src/strategy/maker-points-engine.ts @@ -24,6 +24,13 @@ import { makeOrderPlan } from "../core/lib/order-plan"; import { safeCancelOrder } from "../core/lib/orders"; import { RateLimitController } from "../core/lib/rate-limit"; import { StrategyEventEmitter } from "./common/event-emitter"; +import { + REST_ERROR_DEFENSE_THRESHOLD as STANDX_REST_ERROR_DEFENSE_THRESHOLD, + defenseReasonsFor, + describeDefenseReasons, + evaluateDefense, + type DefenseReasons, +} from "./maker-points-defense"; import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer"; import { safeSubscribe, type LogHandler } from "./common/subscriptions"; import { SessionVolumeTracker } from "./common/session-volume"; @@ -94,10 +101,7 @@ const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000; const STOP_LOSS_COOLDOWN_MS = 5_000; 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; // 账户数据长期无更新阈值(会先尝试通过 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; @@ -446,21 +450,14 @@ export class MakerPointsEngine { this.standxRestConsecutiveErrors = Math.max(this.standxRestConsecutiveErrors, info.consecutiveErrors); this.standxRestLastError = info.error ?? this.standxRestLastError; if (!this.defenseMode && this.standxRestConsecutiveErrors >= STANDX_REST_ERROR_DEFENSE_THRESHOLD) { - this.enterDefenseMode({ - standxDepthStale: false, - binanceStale: false, - standxAccountStale: false, - accountInvalid: false, - standxRestUnhealthy: true, - standxRestConsecutiveErrors: this.standxRestConsecutiveErrors, - standxRestLastError: this.standxRestLastError, - marginModeNotIsolated: false, - marginMode: this.getStandxMarginMode(this.accountSnapshot), - standxDepthAge: 0, - binanceAge: 0, - standxAccountAge: 0, - accountIssues: [], - }); + this.enterDefenseMode( + defenseReasonsFor({ + restUnhealthy: true, + restConsecutiveErrors: this.standxRestConsecutiveErrors, + restLastError: this.standxRestLastError, + marginMode: this.getStandxMarginMode(this.accountSnapshot), + }) + ); } } else if (state === "healthy") { this.standxRestUnhealthy = false; @@ -596,42 +593,29 @@ export class MakerPointsEngine { 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.enterDefenseMode( + defenseReasonsFor({ + marginModeNotIsolated: true, + marginMode: current, + restConsecutiveErrors: this.standxRestConsecutiveErrors, + restLastError: this.standxRestLastError, + }) + ); this.emitUpdate(); return; } const accountHealth = validateAccountSnapshotForSymbol(this.accountSnapshot, this.config.symbol); if (!accountHealth.ok && !this.defenseMode) { - this.enterDefenseMode({ - standxDepthStale: false, - binanceStale: false, - standxAccountStale: false, - accountInvalid: true, - standxRestUnhealthy: false, - standxRestConsecutiveErrors: this.standxRestConsecutiveErrors, - standxRestLastError: this.standxRestLastError, - marginModeNotIsolated: false, - marginMode: this.getStandxMarginMode(this.accountSnapshot), - standxDepthAge: 0, - binanceAge: 0, - standxAccountAge: 0, - accountIssues: accountHealth.issues, - }); + this.enterDefenseMode( + defenseReasonsFor({ + accountInvalid: true, + accountIssues: accountHealth.issues, + restConsecutiveErrors: this.standxRestConsecutiveErrors, + restLastError: this.standxRestLastError, + marginMode: this.getStandxMarginMode(this.accountSnapshot), + }) + ); this.emitUpdate(); return; } @@ -1693,58 +1677,29 @@ export class MakerPointsEngine { */ private checkDataStaleAndDefense(): void { const now = Date.now(); - 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 binanceHealth = this.binanceDepth.getHealth(); - const binanceUnhealthy = !binanceHealth.healthy; + const verdict = evaluateDefense({ + now, + lastDepthTime: this.lastStandxDepthTime, + lastAccountTime: this.lastStandxAccountTime, + lastBinanceDepthTime: this.lastBinanceDepthTime, + binanceHealth: this.binanceDepth.getHealth(), + accountHealth: validateAccountSnapshotForSymbol(this.accountSnapshot, this.config.symbol), + hasAccountSnapshot: this.accountSnapshot != null, + accountProbeFailures: this.accountStaleRestProbeConsecutiveFailures, + accountProbeInFlight: this.accountStaleRestProbeInFlight != null, + restUnhealthy: this.standxRestUnhealthy, + restConsecutiveErrors: this.standxRestConsecutiveErrors, + restLastError: this.standxRestLastError, + marginMode: this.getStandxMarginMode(this.accountSnapshot), + enforceIsolatedMargin: this.exchange.id === "standx", + }); - const standxAccountAge = this.lastStandxAccountTime > 0 ? now - this.lastStandxAccountTime : 0; - const standxAccountStaleByAge = this.lastStandxAccountTime > 0 && standxAccountAge > ACCOUNT_DATA_STALE_THRESHOLD_MS; - if (standxAccountStaleByAge) { + if (verdict.needsAccountProbe) { this.maybeProbeStandxAccountSnapshot(now); } - const standxAccountStale = - 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 = - 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 || - binanceUnhealthy || - standxAccountStale || - accountInvalid || - standxRestUnhealthy || - marginModeNotIsolated; - - if (shouldDefend && !this.defenseMode) { - // 进入防御模式 - this.enterDefenseMode({ - standxDepthStale, - binanceStale, - standxAccountStale, - accountInvalid, - standxRestUnhealthy, - standxRestConsecutiveErrors: this.standxRestConsecutiveErrors, - standxRestLastError: this.standxRestLastError, - marginModeNotIsolated, - marginMode, - binanceUnhealthy, - binanceHealthReason: binanceHealth.reason, - standxDepthAge: this.lastStandxDepthTime > 0 ? now - this.lastStandxDepthTime : 0, - binanceAge: this.lastBinanceDepthTime > 0 ? now - this.lastBinanceDepthTime : 0, - standxAccountAge, - accountIssues: accountInvalid ? accountHealth.issues : [], - }); - } else if (!shouldDefend && this.defenseMode) { - // 退出防御模式 + if (verdict.shouldDefend && !this.defenseMode) { + this.enterDefenseMode(verdict.reasons); + } else if (!verdict.shouldDefend && this.defenseMode) { this.exitDefenseMode(); } } @@ -1753,50 +1708,9 @@ export class MakerPointsEngine { * 进入防御模式 * 取消所有挂单,启动 REST 轮询保护仓位 */ - private enterDefenseMode(staleInfo: { - standxDepthStale: boolean; - binanceStale: boolean; - binanceUnhealthy?: boolean; - binanceHealthReason?: string | null; - standxAccountStale: boolean; - accountInvalid: boolean; - standxRestUnhealthy: boolean; - standxRestConsecutiveErrors: number; - standxRestLastError: string | null; - marginModeNotIsolated: boolean; - marginMode: string | null; - standxDepthAge: number; - binanceAge: number; - standxAccountAge: number; - accountIssues: string[]; - }): void { + private enterDefenseMode(reasons: DefenseReasons): void { this.defenseMode = true; - - // 构建过时信息描述 - const staleItems: string[] = []; - if (staleInfo.standxDepthStale) { - staleItems.push(`StandX深度(${Math.round(staleInfo.standxDepthAge / 1000)}s)`); - } - if (staleInfo.standxAccountStale) { - staleItems.push(`StandX账户(${Math.round(staleInfo.standxAccountAge / 1000)}s)`); - } - if (staleInfo.accountInvalid) { - staleItems.push(`StandX仓位数据异常(${staleInfo.accountIssues.join(",") || "unknown"})`); - } - 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)`); - } - if (staleInfo.binanceUnhealthy && staleInfo.binanceHealthReason) { - staleItems.push(`Binance簿记异常(${staleInfo.binanceHealthReason})`); - } - - const staleSummary = staleItems.length > 0 ? staleItems.join(", ") : "unknown"; + const staleSummary = describeDefenseReasons(reasons); this.tradeLog.push("warn", `数据过时检测: ${staleSummary},进入防御模式`); @@ -1808,7 +1722,7 @@ export class MakerPointsEngine { symbol: this.config.symbol, title: "防御模式", message: `数据推送中断: ${staleSummary},已取消所有挂单`, - details: staleInfo, + details: reasons, }); this.defenseModeNotified = true; }