refactor(maker-points): extract token-expiry and isolated-margin guards

Two more clusters lifted out of the engine, both defined by latches whose only
correctness property is that they move together:

- TokenExpiryGuard owns the five flags (state, logged, notified, cancelDone,
  closeOnly) that make each consequence of an expired StandX token happen once
  per episode and re-arm when a fresh token arrives. evaluate() returns a
  decision instead of a bare boolean, so the tick reads what it means.
- IsolatedMarginGuard owns the single in-flight switch promise that stops
  concurrent ticks from stacking margin-mode change requests, plus the
  poll-until-confirmed loop.

Both were previously reachable only through a live adapter; they now have 22
unit tests between them, covering the latch reset across a token renewal, the
cancel retry after a failure, unknown-order treated as success, and the
concurrent-tick sharing of one margin switch.

Confirm cadence kept at 500ms x 10 to match the engine's original constants.
271 pass; tsc and oxlint clean. Engine 1939 -> 1826 lines.
This commit is contained in:
discountry
2026-07-29 21:23:26 +08:00
parent 5aecaabb16
commit 448a2f2615
5 changed files with 548 additions and 156 deletions
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it, vi } from "vitest";
import { IsolatedMarginGuard } from "../src/strategy/common/isolated-margin-guard";
import type { AccountSnapshot } from "../src/exchanges/types";
const SYMBOL = "BTC-USD";
function snapshotWithMode(mode: string | null): AccountSnapshot {
return {
positions: [{ symbol: SYMBOL, ...(mode ? { marginType: mode } : {}) }],
} as unknown as AccountSnapshot;
}
function makeGuard(options: {
enabled?: boolean;
initialMode?: string | null;
/** Modes the account reports on successive polls. */
polledModes?: Array<string | null>;
changeMarginMode?: (params: { symbol: string; marginMode: "isolated" | "cross" }) => Promise<void>;
omitCapabilities?: boolean;
} = {}) {
const logs: Array<[string, string]> = [];
let current = snapshotWithMode("initialMode" in options ? options.initialMode! : "cross");
const polled = [...(options.polledModes ?? [])];
const queryAccountSnapshot = vi.fn(async () => snapshotWithMode(polled.shift() ?? "cross"));
const changeMarginMode = vi.fn(options.changeMarginMode ?? (async () => {}));
const guard = new IsolatedMarginGuard({
symbol: SYMBOL,
enabled: options.enabled ?? true,
log: (type, detail) => logs.push([type, detail]),
currentSnapshot: () => current,
changeMarginMode: options.omitCapabilities ? undefined : changeMarginMode,
queryAccountSnapshot: options.omitCapabilities ? undefined : queryAccountSnapshot,
applySnapshot: (next) => {
current = next;
},
// No real waiting in tests.
sleep: async () => {},
});
return { guard, logs, changeMarginMode, queryAccountSnapshot };
}
describe("IsolatedMarginGuard", () => {
it("is inert on venues without a per-symbol margin mode", async () => {
const { guard, changeMarginMode } = makeGuard({ enabled: false });
expect(await guard.ensureIsolated()).toBe(true);
expect(guard.currentMode()).toBeNull();
expect(changeMarginMode).not.toHaveBeenCalled();
});
it("does nothing when already isolated", async () => {
const { guard, changeMarginMode } = makeGuard({ initialMode: "isolated" });
expect(await guard.ensureIsolated()).toBe(true);
expect(changeMarginMode).not.toHaveBeenCalled();
});
it("normalises the reported mode", async () => {
const { guard } = makeGuard({ initialMode: " ISOLATED " });
expect(guard.currentMode()).toBe("isolated");
});
it("reports an unknown mode as null", async () => {
const { guard } = makeGuard({ initialMode: null });
expect(guard.currentMode()).toBeNull();
});
it("switches and confirms through a snapshot poll", async () => {
const { guard, logs, changeMarginMode } = makeGuard({
initialMode: "cross",
polledModes: ["cross", "isolated"],
});
expect(await guard.ensureIsolated()).toBe(true);
expect(changeMarginMode).toHaveBeenCalledWith({ symbol: SYMBOL, marginMode: "isolated" });
expect(logs.some(([, detail]) => detail.includes("已切换为逐仓模式"))).toBe(true);
});
it("gives up after the confirm attempts run out", async () => {
const { guard, logs, queryAccountSnapshot } = makeGuard({ polledModes: [] });
expect(await guard.ensureIsolated()).toBe(false);
expect(queryAccountSnapshot).toHaveBeenCalledTimes(10);
expect(logs.some(([type]) => type === "warn")).toBe(true);
});
it("reports failure when the venue rejects the change", async () => {
const { guard, logs } = makeGuard({
changeMarginMode: async () => {
throw new Error("rejected");
},
});
expect(await guard.ensureIsolated()).toBe(false);
expect(logs.some(([type]) => type === "error")).toBe(true);
});
it("returns false when the adapter cannot change margin mode", async () => {
const { guard } = makeGuard({ omitCapabilities: true });
expect(await guard.ensureIsolated()).toBe(false);
});
it("shares one in-flight switch across concurrent ticks", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const { guard, changeMarginMode } = makeGuard({
polledModes: ["isolated"],
changeMarginMode: async () => {
await gate;
},
});
const first = guard.ensureIsolated();
// A tick arriving mid-switch must not fire a second change request.
const second = await guard.ensureIsolated();
expect(second).toBe(false);
release();
expect(await first).toBe(true);
expect(changeMarginMode).toHaveBeenCalledTimes(1);
});
it("allows a fresh attempt after the previous one settles", async () => {
const { guard, changeMarginMode } = makeGuard({ polledModes: [] });
expect(await guard.ensureIsolated()).toBe(false);
expect(await guard.ensureIsolated()).toBe(false);
expect(changeMarginMode).toHaveBeenCalledTimes(2);
});
});
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { standxTokenConfig } from "../src/config";
import { TokenExpiryGuard } from "../src/strategy/common/token-expiry-guard";
const HOUR_MS = 3_600_000;
const original = standxTokenConfig.expiryTimestamp;
/** The config field is read on every call, so tests set it directly. */
function setExpiry(atMs: number | null): void {
standxTokenConfig.expiryTimestamp = atMs;
}
function makeGuard() {
const logs: Array<[string, string]> = [];
const notifications: unknown[] = [];
const cancelAllOrders = vi.fn(async () => {});
const onOrdersCancelled = vi.fn();
const guard = new TokenExpiryGuard({
log: (type, detail) => logs.push([type, detail]),
notify: (n) => notifications.push(n),
cancelAllOrders,
onOrdersCancelled,
});
return { guard, logs, notifications, cancelAllOrders, onOrdersCancelled };
}
describe("TokenExpiryGuard", () => {
afterEach(() => {
standxTokenConfig.expiryTimestamp = original;
});
it("stays out of the way when no expiry is configured", async () => {
setExpiry(null);
const { guard, cancelAllOrders } = makeGuard();
const decision = await guard.evaluate({ positionAmt: 1, openOrderCount: 3 });
expect(decision).toEqual({ halt: false, closeOnly: false });
expect(cancelAllOrders).not.toHaveBeenCalled();
});
it("does nothing while the token is still valid", async () => {
setExpiry(Date.now() + HOUR_MS * 24);
const { guard, cancelAllOrders, notifications } = makeGuard();
const decision = await guard.evaluate({ positionAmt: 0, openOrderCount: 0 });
expect(decision).toEqual({ halt: false, closeOnly: false });
expect(cancelAllOrders).not.toHaveBeenCalled();
expect(notifications).toHaveLength(0);
});
it("cancels once and keeps ticking while a position is still open", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard, cancelAllOrders, onOrdersCancelled } = makeGuard();
const first = await guard.evaluate({ positionAmt: 2, openOrderCount: 4 });
expect(first).toEqual({ halt: false, closeOnly: true });
expect(cancelAllOrders).toHaveBeenCalledTimes(1);
expect(onOrdersCancelled).toHaveBeenCalledTimes(1);
await guard.evaluate({ positionAmt: 2, openOrderCount: 4 });
expect(cancelAllOrders).toHaveBeenCalledTimes(1);
});
it("logs and notifies exactly once per episode", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard, logs, notifications } = makeGuard();
await guard.evaluate({ positionAmt: 2, openOrderCount: 1 });
await guard.evaluate({ positionAmt: 2, openOrderCount: 1 });
await guard.evaluate({ positionAmt: 2, openOrderCount: 1 });
expect(notifications).toHaveLength(1);
expect(logs.filter(([type]) => type === "warn")).toHaveLength(1);
});
it("halts the tick once nothing is left to manage", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard } = makeGuard();
expect((await guard.evaluate({ positionAmt: 0, openOrderCount: 0 })).halt).toBe(true);
});
it("announces the silent mode only on entry", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard, logs } = makeGuard();
await guard.evaluate({ positionAmt: 0, openOrderCount: 0 });
await guard.evaluate({ positionAmt: 0, openOrderCount: 0 });
const entryLogs = logs.filter(
([type, detail]) => type === "info" && detail.includes("静默数据接收模式")
);
expect(entryLogs).toHaveLength(1);
});
it("retries the cancel on the next tick when it fails", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard, cancelAllOrders, logs } = makeGuard();
cancelAllOrders.mockRejectedValueOnce(new Error("network down"));
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
expect(logs.some(([type]) => type === "error")).toBe(true);
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
expect(cancelAllOrders).toHaveBeenCalledTimes(2);
});
it("treats an already-gone order as a successful cancel", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard, cancelAllOrders } = makeGuard();
cancelAllOrders.mockRejectedValueOnce(new Error("Unknown order sent."));
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
expect(cancelAllOrders).toHaveBeenCalledTimes(1);
});
it("skips the cancel when there is nothing resting", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard, cancelAllOrders } = makeGuard();
await guard.evaluate({ positionAmt: 1, openOrderCount: 0 });
expect(cancelAllOrders).not.toHaveBeenCalled();
});
it("exposes closeOnlyMode for the engine's close-reason label", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard } = makeGuard();
expect(guard.closeOnlyMode).toBe(false);
await guard.evaluate({ positionAmt: 3, openOrderCount: 0 });
expect(guard.closeOnlyMode).toBe(true);
});
it("re-arms every latch once a fresh token arrives", async () => {
// The five latches must reset together; a stale one would silently suppress
// the log, alert, or cancel for the next expiry.
setExpiry(Date.now() - HOUR_MS);
const { guard, notifications, cancelAllOrders, logs } = makeGuard();
await guard.evaluate({ positionAmt: 5, openOrderCount: 1 });
expect(guard.closeOnlyMode).toBe(true);
expect(notifications).toHaveLength(1);
setExpiry(Date.now() + HOUR_MS * 24);
await guard.evaluate({ positionAmt: 5, openOrderCount: 1 });
expect(guard.closeOnlyMode).toBe(false);
expect(guard.currentState).toBe("active");
setExpiry(Date.now() - HOUR_MS);
await guard.evaluate({ positionAmt: 5, openOrderCount: 1 });
expect(notifications).toHaveLength(2);
expect(cancelAllOrders).toHaveBeenCalledTimes(2);
expect(logs.filter(([type]) => type === "warn")).toHaveLength(2);
});
});