mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 17:28:08 +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,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";
|
||||
}
|
||||
Reference in New Issue
Block a user