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
+1
View File
@@ -520,6 +520,7 @@ function resolveEffectiveSymbol(explicit: string | undefined, exchange: Supporte
function runtimeCapabilities(adapter: ExchangeAdapter): unknown {
return {
trailingStops: adapter.supportsTrailingStops(),
triggerOrders: adapter.supportsTriggerOrders?.() ?? false,
fundingRate: typeof adapter.watchFundingRate === "function",
precision: typeof adapter.getPrecision === "function",
queryOpenOrders: typeof adapter.queryOpenOrders === "function",
+16
View File
@@ -293,6 +293,14 @@ export interface GridConfig {
autoRestart: boolean;
gridMode: "geometric";
maxCloseSlippagePct: number;
gridShiftEnabled: boolean;
gridShiftTriggerPct: number;
gridShiftRangePct: number;
gridShiftConfirmMs: number;
useReduceOnlyForExit: boolean;
exchangeStopEnabled: boolean;
reconcileIntervalMs: number;
uncoveredGraceMs: number;
}
const resolveBasisSymbol = (envKeys: string[], fallback: string): string => {
@@ -373,6 +381,14 @@ export const gridConfig: GridConfig = {
0.05
)
),
gridShiftEnabled: parseBoolean(process.env.GRID_SHIFT_ENABLED, false),
gridShiftTriggerPct: Math.max(0, parseNumber(process.env.GRID_SHIFT_TRIGGER_PCT, 0.05)),
gridShiftRangePct: Math.max(0, parseNumber(process.env.GRID_SHIFT_RANGE_PCT, 0.05)),
gridShiftConfirmMs: Math.max(0, parseNumber(process.env.GRID_SHIFT_CONFIRM_MS, 3000)),
useReduceOnlyForExit: parseBoolean(process.env.GRID_USE_REDUCE_ONLY, false),
exchangeStopEnabled: parseBoolean(process.env.GRID_EXCHANGE_STOP_ENABLED, true),
reconcileIntervalMs: Math.max(1000, parseNumber(process.env.GRID_RECONCILE_INTERVAL_MS, 30_000)),
uncoveredGraceMs: Math.max(0, parseNumber(process.env.GRID_UNCOVERED_GRACE_MS, 5000)),
};
gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.orderSize, gridConfig.gridLevels);
+2
View File
@@ -66,6 +66,8 @@ export interface ConnectionEventListener {
export interface ExchangeAdapter {
readonly id: string;
supportsTrailingStops(): boolean;
/** 是否支持交易所侧触发单(STOP_MARKET 兜底止损),缺省视为 false */
supportsTriggerOrders?(): boolean;
watchAccount(cb: AccountListener): void;
watchOrders(cb: OrderListener): void;
watchDepth(symbol: string, cb: DepthListener): void;
+4
View File
@@ -36,6 +36,10 @@ export class AsterExchangeAdapter implements ExchangeAdapter {
return true;
}
supportsTriggerOrders(): boolean {
return true;
}
watchAccount(cb: AccountListener): void {
void this.init.ensureInitialized("watchAccount");
this.gateway.onAccount(this.safeInvoke("watchAccount", (snapshot) => {
+4
View File
@@ -66,6 +66,10 @@ export class BinanceExchangeAdapter implements ExchangeAdapter {
return this.marketType !== "spot";
}
supportsTriggerOrders(): boolean {
return this.marketType !== "spot";
}
watchAccount(cb: AccountListener): void {
const safe = this.safeInvoke("watchAccount", cb);
void this.init.ensureInitialized("watchAccount")
+4
View File
@@ -41,6 +41,10 @@ export class DryRunExchangeAdapter implements ExchangeAdapter {
return this.inner.supportsTrailingStops();
}
supportsTriggerOrders(): boolean {
return this.inner.supportsTriggerOrders?.() ?? false;
}
watchAccount(cb: AccountListener): void {
this.inner.watchAccount(cb);
}
+4
View File
@@ -96,6 +96,10 @@ export class GrvtExchangeAdapter implements ExchangeAdapter {
return false;
}
supportsTriggerOrders(): boolean {
return true;
}
watchAccount(cb: AccountListener): void {
void this.init.ensureInitialized("watchAccount");
this.gateway.onAccount(this.safeInvoke("watchAccount", cb));
+4
View File
@@ -59,6 +59,10 @@ export class OndoperpsExchangeAdapter implements ExchangeAdapter {
return false;
}
supportsTriggerOrders(): boolean {
return true;
}
watchAccount(cb: AccountListener): void {
void this.init.ensureInitialized("watchAccount");
this.gateway.onAccount(this.safeInvoke("watchAccount", cb));
+10
View File
@@ -288,6 +288,16 @@ const translations: Record<string, TranslationEntry> = {
en: "Last price: {lastPrice} | Lower: {lower} | Upper: {upper} | Grid count: {count}",
},
"grid.dataStatus": { zh: "数据状态:", en: "Data status:" },
"grid.anchorLine": {
zh: "锚定价: {anchor} 网格版本: v{version}",
en: "Anchor: {anchor} | Grid version: v{version}",
},
"grid.shiftState": { zh: "移格进行中: {phase}", en: "Shifting: {phase}" },
"grid.stopProtection": {
zh: "止损防护: 未覆盖 {uncovered} 兜底止损单: {stop}",
en: "Stop protection: uncovered {uncovered} | exchange stop: {stop}",
},
"grid.stopProtection.none": { zh: "无", en: "none" },
"grid.stopReason": { zh: "暂停原因: {reason}", en: "Pause reason: {reason}" },
"grid.configTitle": { zh: "网格配置", en: "Grid Config" },
"grid.configSize": {
+68 -27
View File
@@ -1,41 +1,80 @@
import { promises as fs } from "fs";
import path from "path";
import type { GridDirection } from "../../config";
import type { LevelPhase, StoredGridStateV2, StoredLevelV2 } from "../grid-logic";
const DATA_DIR = process.env.GRID_DATA_DIR?.trim() || path.resolve("data");
const GRID_FILE = path.resolve(DATA_DIR, "grid-record.json");
export type { LevelPhase, StoredGridStateV2, StoredLevelV2 };
/** State of a single grid level */
export type LevelState = "idle" | "filled" | "exit_placed";
export interface StoredLevelInfo {
state: LevelState;
/** The grid level index where ENTRY was filled */
sourceLevel: number;
/** The grid level index where EXIT is targeted (closeTarget) */
targetLevel: number | null;
/** The orderId of the EXIT order on exchange (if exit_placed) */
exitOrderId?: string;
// 惰性解析,测试可通过 GRID_DATA_DIR 切换目录
function dataDir(): string {
return process.env.GRID_DATA_DIR?.trim() || path.resolve("data");
}
export interface StoredGridState {
function gridFile(): string {
return path.resolve(dataDir(), "grid-record.json");
}
/** v1 遗留格式(无 schemaVersion 字段) */
interface StoredGridStateV1 {
symbol: string;
lowerPrice: number;
upperPrice: number;
gridLevels: number;
orderSize: number;
maxPositionSize: number;
direction: GridDirection;
/** Per-level state: key is level index string */
levels: Record<string, StoredLevelInfo>;
direction: string;
levels: Record<
string,
{ state: "idle" | "filled" | "exit_placed"; sourceLevel: number; targetLevel: number | null; exitOrderId?: string }
>;
updatedAt: number;
}
type GridStateMap = Record<string, StoredGridState>;
type StoredGridStateAny = StoredGridStateV1 | StoredGridStateV2;
type GridStateMap = Record<string, StoredGridStateAny>;
function isV2(entry: StoredGridStateAny): entry is StoredGridStateV2 {
return (entry as StoredGridStateV2).schemaVersion === 2;
}
/** v1 → v2filled→holding、exit_placed→exit_placedholdQty 取 orderSize,锚定价缺失由引擎补齐 */
export function migrateV1ToV2(v1: StoredGridStateV1): StoredGridStateV2 {
const levels: Record<string, StoredLevelV2> = {};
for (const [key, info] of Object.entries(v1.levels ?? {})) {
if (!info || info.state === "idle") continue;
const phase: LevelPhase = info.state === "filled" ? "holding" : "exit_placed";
const entry: StoredLevelV2 = {
phase,
exitTarget: info.targetLevel ?? null,
holdQty: Number.isFinite(v1.orderSize) ? v1.orderSize : 0,
};
if (info.exitOrderId) entry.exitOrderId = info.exitOrderId;
levels[key] = entry;
}
return {
schemaVersion: 2,
symbol: v1.symbol,
exchangeId: "",
gridVersion: 1,
anchorPrice: null,
lowerPrice: v1.lowerPrice,
upperPrice: v1.upperPrice,
gridLevels: v1.gridLevels,
orderSize: v1.orderSize,
maxPositionSize: v1.maxPositionSize,
direction: v1.direction,
gridMode: "geometric",
levels,
intents: [],
inflight: null,
shift: null,
exchangeStop: null,
updatedAt: v1.updatedAt ?? 0,
};
}
async function ensureDataDir(): Promise<void> {
try {
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.mkdir(dataDir(), { recursive: true });
} catch {
// ignore
}
@@ -43,7 +82,7 @@ async function ensureDataDir(): Promise<void> {
async function readStateFile(): Promise<GridStateMap> {
try {
const content = await fs.readFile(GRID_FILE, "utf8");
const content = await fs.readFile(gridFile(), "utf8");
const parsed = JSON.parse(content);
if (parsed && typeof parsed === "object") {
return parsed as GridStateMap;
@@ -57,17 +96,19 @@ async function readStateFile(): Promise<GridStateMap> {
}
}
export async function loadGridState(symbol: string): Promise<StoredGridState | null> {
export async function loadGridState(symbol: string): Promise<StoredGridStateV2 | null> {
const map = await readStateFile();
const snapshot = map[symbol];
return snapshot ?? null;
if (!snapshot) return null;
if (isV2(snapshot)) return snapshot;
return migrateV1ToV2(snapshot);
}
export async function saveGridState(snapshot: StoredGridState): Promise<void> {
export async function saveGridState(snapshot: StoredGridStateV2): Promise<void> {
await ensureDataDir();
const map = await readStateFile();
map[snapshot.symbol] = snapshot;
await fs.writeFile(GRID_FILE, JSON.stringify(map, null, 2), "utf8");
await fs.writeFile(gridFile(), JSON.stringify(map, null, 2), "utf8");
}
export async function clearGridState(symbol: string): Promise<void> {
@@ -79,7 +120,7 @@ export async function clearGridState(symbol: string): Promise<void> {
const entries = Object.keys(map);
if (!entries.length) {
try {
await fs.unlink(GRID_FILE);
await fs.unlink(gridFile());
} catch (error: any) {
if (!error || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) {
throw error;
@@ -88,5 +129,5 @@ export async function clearGridState(symbol: string): Promise<void> {
return;
}
await ensureDataDir();
await fs.writeFile(GRID_FILE, JSON.stringify(map, null, 2), "utf8");
await fs.writeFile(gridFile(), JSON.stringify(map, null, 2), "utf8");
}
+773 -1295
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21 -2
View File
@@ -90,15 +90,17 @@ export function GridApp({ onExit }: GridAppProps) {
{ key: "level", header: "#", align: "right", minWidth: 3 },
{ key: "price", header: "Price", align: "right", minWidth: 10 },
{ key: "side", header: "Side", minWidth: 4 },
{ key: "active", header: "Active", minWidth: 6 },
{ key: "state", header: "State", minWidth: 11 },
{ key: "hasOrder", header: "Order", minWidth: 5 },
{ key: "hold", header: "Hold", align: "right", minWidth: 8 },
];
const gridRows = snapshot.gridLines.map((line) => ({
level: line.level,
price: formatNumber(line.price, 4),
side: line.side,
active: line.active ? "yes" : "no",
state: line.state,
hasOrder: line.hasOrder ? "yes" : "no",
hold: line.holdQty > 0 ? formatNumber(line.holdQty, 4) : "-",
}));
const desiredColumns: TableColumn[] = [
@@ -141,6 +143,23 @@ export function GridApp({ onExit }: GridAppProps) {
count: snapshot.gridLines.length,
})}
</Text>
<Text>
{t("grid.anchorLine", {
anchor: formatNumber(snapshot.anchorPrice, 4),
version: snapshot.gridVersion,
})}
{snapshot.shiftPhase ? (
<Text color="yellow"> | {t("grid.shiftState", { phase: snapshot.shiftPhase })}</Text>
) : null}
</Text>
<Text color={snapshot.stopProtection.uncoveredQty > 0 ? "yellow" : "gray"}>
{t("grid.stopProtection", {
uncovered: formatNumber(snapshot.stopProtection.uncoveredQty, 6),
stop: snapshot.stopProtection.exchangeStop
? `${snapshot.stopProtection.exchangeStop.side} @ ${formatNumber(snapshot.stopProtection.exchangeStop.stopPrice, 4)}`
: t("grid.stopProtection.none"),
})}
</Text>
<Text color="gray">
{t("grid.dataStatus")}
{feedEntries.map((entry, index) => (
+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);
});
});