mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 01:08:07 +00:00
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.
181 lines
6.6 KiB
TypeScript
181 lines
6.6 KiB
TypeScript
/**
|
|
* 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";
|
|
}
|