mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
refactor(maker-points): extract defense-mode decision into pure logic
checkDataStaleAndDefense mixed the question (are these feeds trustworthy?) with the answer (probe over REST, cancel everything, start polling), inside a 2063-line class, so the rule could only be exercised through a live adapter. Separate Query from Modifier: maker-points-defense.ts takes feed ages and health flags and returns a verdict; the engine keeps every action. Follows the existing maker-points-logic.ts / grid-logic.ts convention. Three other enterDefenseMode call sites each spelled out the same 14-field stale info, 11 fields of which were false/0/null padding. defenseReasonsFor() states only the known cause. 16 new unit tests cover what previously had none — the age-0 startup case, the threshold boundary, and the probe-then-defend sequence for a quiet account feed. 250 pass; tsc and oxlint clean. Engine 2063 -> 1939 lines.
This commit is contained in:
@@ -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> = {}): 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");
|
||||
});
|
||||
});
|
||||
@@ -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>): 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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user