feat(grid): add grid shift, exchange stops, and reconcile safeguards

This commit is contained in:
discountry
2026-07-21 15:14:26 +08:00
parent 7efd64ef29
commit ab47c82ebc
16 changed files with 4043 additions and 1859 deletions
+604 -535
View File
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { promises as fs } from "fs";
import os from "os";
import path from "path";
import {
loadGridState,
saveGridState,
clearGridState,
migrateV1ToV2,
type StoredGridStateV2,
} from "../src/strategy/common/grid-storage";
let tmpDir: string;
let prevDataDir: string | undefined;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "grid-storage-test-"));
prevDataDir = process.env.GRID_DATA_DIR;
process.env.GRID_DATA_DIR = tmpDir;
});
afterEach(async () => {
if (prevDataDir == null) delete process.env.GRID_DATA_DIR;
else process.env.GRID_DATA_DIR = prevDataDir;
await fs.rm(tmpDir, { recursive: true, force: true });
});
function makeV2(symbol: string): StoredGridStateV2 {
return {
schemaVersion: 2,
symbol,
exchangeId: "aster",
gridVersion: 3,
anchorPrice: 150,
lowerPrice: 100,
upperPrice: 200,
gridLevels: 5,
orderSize: 0.1,
maxPositionSize: 0.4,
direction: "neutral",
gridMode: "geometric",
levels: {
"1": { phase: "holding", exitTarget: 2, holdQty: 0.1 },
"3": { phase: "exit_placed", exitTarget: 2, holdQty: 0.1, exitOrderId: "o-9" },
},
intents: [
{
orderId: "o-9",
clientOrderId: "grid-3-X-3-2-abc",
intent: "EXIT",
side: "BUY",
price: "141.4",
qty: 0.1,
level: 3,
target: 2,
gridVersion: 3,
createdAt: 1000,
},
],
inflight: null,
shift: { phase: "closing", targetAnchor: 210, startedAt: 2000 },
exchangeStop: { orderId: "stop-1", side: "SELL", stopPrice: 99 },
updatedAt: 3000,
};
}
describe("grid-storage v2", () => {
it("round-trips a v2 snapshot", async () => {
const snapshot = makeV2("BTCUSDT");
await saveGridState(snapshot);
const loaded = await loadGridState("BTCUSDT");
expect(loaded).toEqual(snapshot);
});
it("returns null for unknown symbols", async () => {
expect(await loadGridState("NONE")).toBeNull();
});
it("keeps entries for other symbols on clear", async () => {
await saveGridState(makeV2("BTCUSDT"));
await saveGridState(makeV2("ETHUSDT"));
await clearGridState("BTCUSDT");
expect(await loadGridState("BTCUSDT")).toBeNull();
expect(await loadGridState("ETHUSDT")).not.toBeNull();
});
it("removes the file when the last symbol is cleared", async () => {
await saveGridState(makeV2("BTCUSDT"));
await clearGridState("BTCUSDT");
await expect(fs.stat(path.join(tmpDir, "grid-record.json"))).rejects.toMatchObject({
code: "ENOENT",
});
});
});
describe("grid-storage v1 migration", () => {
const v1Entry = {
symbol: "BTCUSDT",
lowerPrice: 100,
upperPrice: 200,
gridLevels: 5,
orderSize: 0.1,
maxPositionSize: 0.4,
direction: "both",
levels: {
"0": { state: "filled", sourceLevel: 0, targetLevel: 1 },
"1": { state: "exit_placed", sourceLevel: 1, targetLevel: 2, exitOrderId: "legacy-1" },
"2": { state: "idle", sourceLevel: 2, targetLevel: null },
},
updatedAt: 1234,
};
it("loads v1 entries as migrated v2", async () => {
await fs.writeFile(
path.join(tmpDir, "grid-record.json"),
JSON.stringify({ BTCUSDT: v1Entry }),
"utf8"
);
const loaded = await loadGridState("BTCUSDT");
expect(loaded).not.toBeNull();
expect(loaded!.schemaVersion).toBe(2);
expect(loaded!.gridVersion).toBe(1);
expect(loaded!.anchorPrice).toBeNull();
expect(loaded!.levels["0"]).toEqual({ phase: "holding", exitTarget: 1, holdQty: 0.1 });
expect(loaded!.levels["1"]).toEqual({
phase: "exit_placed",
exitTarget: 2,
holdQty: 0.1,
exitOrderId: "legacy-1",
});
expect(loaded!.levels["2"]).toBeUndefined();
expect(loaded!.intents).toEqual([]);
});
it("migrateV1ToV2 preserves config fingerprint fields", () => {
const migrated = migrateV1ToV2(v1Entry as any);
expect(migrated.symbol).toBe("BTCUSDT");
expect(migrated.direction).toBe("both");
expect(migrated.orderSize).toBe(0.1);
expect(migrated.gridLevels).toBe(5);
expect(migrated.gridMode).toBe("geometric");
expect(migrated.lowerPrice).toBe(100);
expect(migrated.upperPrice).toBe(200);
});
});