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:
discountry
2026-07-29 20:43:06 +08:00
parent 2c98664412
commit 6f64dd5a0a
3 changed files with 401 additions and 141 deletions
+166
View File
@@ -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");
});
});