feat: 添加最大平仓滑点配置,更新网格引擎状态持久化逻辑,优化订单管理和状态恢复功能

This commit is contained in:
discountry
2025-10-07 03:48:12 +08:00
parent 5878a3dc0b
commit 88292c9c49
6 changed files with 268 additions and 9 deletions
+1
View File
@@ -49,6 +49,7 @@ GRID_DIRECTION=both # Order direction: both | long | short
GRID_STOP_LOSS_PCT=0.01 # Stop loss trigger percentage beyond bounds (0.01 => 1%)
GRID_RESTART_TRIGGER_PCT=0.01 # Restart buffer percentage inside bounds
GRID_AUTO_RESTART_ENABLED=true # Automatically resume grid when price re-enters range
GRID_MAX_CLOSE_SLIPPAGE_PCT=0.05 # Close-order slippage guard relative to mark price
# GRID_PRICE_TICK=0.1 # Optional override for grid price tick (falls back to PRICE_TICK)
# GRID_QTY_STEP=0.001 # Optional override for grid quantity step (falls back to QTY_STEP)
+5 -3
View File
@@ -30,8 +30,9 @@
GRID_DIRECTION=both
GRID_STOP_LOSS_PCT=0.02
GRID_RESTART_TRIGGER_PCT=0.02
GRID_AUTO_RESTART_ENABLED=true
```
GRID_AUTO_RESTART_ENABLED=true
GRID_MAX_CLOSE_SLIPPAGE_PCT=0.05
```
- `GRID_ORDER_SIZE` 与 `GRID_MAX_POSITION_SIZE` 需遵循「最大仓位 ÷ 单笔数量 ≥ 网格数」的原则,这样策略才能补齐全部挂单。本例 50 ÷ 5 = 10,但网格数为 20,意味着策略只会在离现价最近的上下各 10 个位置挂单,与仓位上限保持一致。
@@ -69,7 +70,8 @@ bun start
调参建议:
1. **缩短区间**:想拉高单格盈利,可缩小上下边界并减少网格数。
2. **更精细挂单**:适当提高 `GRID_LEVELS` 并降低 `GRID_ORDER_SIZE`,但同时记得调大 `GRID_MAX_POSITION_SIZE`。
3. **只做单边**:若只想高抛低吸不反手,可设 `GRID_DIRECTION=long`,卖单会变成 `reduceOnly`
3. **调节平仓容忍度**`GRID_MAX_CLOSE_SLIPPAGE_PCT` 控制平仓单相对标记价的最大偏移,确保 reduce-only 订单不会被交易所拒绝
4. **只做单边**:若只想高抛低吸不反手,可设 `GRID_DIRECTION=long`,卖单会变成 `reduceOnly`。
## 中断恢复行为
+8
View File
@@ -133,6 +133,7 @@ export interface GridConfig {
restartTriggerPct: number;
autoRestart: boolean;
gridMode: "geometric";
maxCloseSlippagePct: number;
}
const resolveBasisSymbol = (envKeys: string[], fallback: string): string => {
@@ -191,6 +192,13 @@ export const gridConfig: GridConfig = {
restartTriggerPct: Math.max(0, parseNumber(process.env.GRID_RESTART_TRIGGER_PCT, 0.01)),
autoRestart: parseBoolean(process.env.GRID_AUTO_RESTART_ENABLED ?? process.env.GRID_ENABLE_AUTO_RESTART, true),
gridMode: "geometric",
maxCloseSlippagePct: Math.max(
0,
parseNumber(
process.env.GRID_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT,
0.05
)
),
};
gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.orderSize, gridConfig.gridLevels);
+58
View File
@@ -0,0 +1,58 @@
import { promises as fs } from "fs";
import path from "path";
import type { GridDirection } from "../../config";
const DATA_DIR = process.env.GRID_DATA_DIR?.trim() || path.resolve("data");
const GRID_FILE = path.resolve(DATA_DIR, "grid-record.json");
export interface StoredGridState {
symbol: string;
lowerPrice: number;
upperPrice: number;
gridLevels: number;
orderSize: number;
maxPositionSize: number;
direction: GridDirection;
longExposure: Record<string, number>;
shortExposure: Record<string, number>;
updatedAt: number;
}
type GridStateMap = Record<string, StoredGridState>;
async function ensureDataDir(): Promise<void> {
try {
await fs.mkdir(DATA_DIR, { recursive: true });
} catch {
// ignore
}
}
async function readStateFile(): Promise<GridStateMap> {
try {
const content = await fs.readFile(GRID_FILE, "utf8");
const parsed = JSON.parse(content);
if (parsed && typeof parsed === "object") {
return parsed as GridStateMap;
}
return {};
} catch (error: any) {
if (error && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
return {};
}
throw error;
}
}
export async function loadGridState(symbol: string): Promise<StoredGridState | null> {
const map = await readStateFile();
const snapshot = map[symbol];
return snapshot ?? null;
}
export async function saveGridState(snapshot: StoredGridState): Promise<void> {
await ensureDataDir();
const map = await readStateFile();
map[snapshot.symbol] = snapshot;
await fs.writeFile(GRID_FILE, JSON.stringify(map, null, 2), "utf8");
}
+195 -6
View File
@@ -17,6 +17,7 @@ import {
import { safeCancelOrder } from "../core/lib/orders";
import { StrategyEventEmitter } from "./common/event-emitter";
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { loadGridState, saveGridState, type StoredGridState } from "./common/grid-storage";
interface DesiredGridOrder {
level: number;
@@ -96,6 +97,7 @@ export class GridEngine {
{ side: "BUY" | "SELL"; level: number; quantity: number; reduceOnly: boolean }
>();
private readonly pendingCancelKeys = new Set<string>();
private statePersistTimer: ReturnType<typeof setTimeout> | null = null;
private accountSnapshot: AsterAccountSnapshot | null = null;
private depthSnapshot: AsterDepth | null = null;
@@ -135,6 +137,7 @@ export class GridEngine {
this.configValid = this.validateConfig();
this.gridLevels = this.computeGridLevels();
this.buildLevelMeta();
void this.restoreState();
this.running = this.configValid;
if (!this.configValid) {
this.stopReason = "配置无效,已暂停网格";
@@ -299,6 +302,7 @@ export class GridEngine {
if (!Number.isFinite(price) || price === null) {
return;
}
await this.enforceExposureSafety(price);
if (this.shouldStop(price)) {
await this.haltGrid(price);
return;
@@ -353,6 +357,7 @@ export class GridEngine {
this.shortExposure.clear();
this.lastOrderBook.clear();
this.pendingCancelKeys.clear();
this.schedulePersist();
}
private async closePosition(): Promise<void> {
@@ -553,20 +558,24 @@ export class GridEngine {
}
for (const [level, quantity] of longCloseRequirements) {
const rawPrice = this.gridLevels[level]!;
const clamped = this.clampClosePrice({ side: "SELL", rawPrice });
desired.push({
level,
side: "SELL",
price: this.formatPrice(this.gridLevels[level]!),
price: this.formatPrice(clamped),
amount: quantity,
reduceOnly: true,
});
}
for (const [level, quantity] of shortCloseRequirements) {
const rawPrice = this.gridLevels[level]!;
const clamped = this.clampClosePrice({ side: "BUY", rawPrice });
desired.push({
level,
side: "BUY",
price: this.formatPrice(this.gridLevels[level]!),
price: this.formatPrice(clamped),
amount: quantity,
reduceOnly: true,
});
@@ -575,6 +584,166 @@ export class GridEngine {
return desired;
}
private clampClosePrice(params: { side: "BUY" | "SELL"; rawPrice: number }): number {
const slippage = Math.max(0, this.config.maxCloseSlippagePct);
if (slippage <= 0) return params.rawPrice;
const referenceCandidates = [
Number.isFinite(this.position.markPrice) ? this.position.markPrice : null,
this.getReferencePrice(),
];
const mark = referenceCandidates.find((value) => Number.isFinite(value) && Number(value) > 0);
if (!Number.isFinite(mark) || Number(mark) <= 0) {
return params.rawPrice;
}
const markNumber = Number(mark);
if (params.side === "SELL") {
const floor = markNumber * (1 - slippage);
return Math.max(params.rawPrice, floor);
}
const ceiling = markNumber * (1 + slippage);
return Math.min(params.rawPrice, ceiling);
}
private async enforceExposureSafety(referencePrice: number): Promise<void> {
const toCloseLongLevels: Array<{ level: number; quantity: number }> = [];
for (const [level, quantity] of this.longExposure) {
if (quantity <= EPSILON) continue;
const target = this.levelMeta[level]?.closeTarget;
if (target == null) continue;
const closePrice = this.gridLevels[target]!;
if (referencePrice >= closePrice - this.config.priceTick / 2) {
toCloseLongLevels.push({ level, quantity });
}
}
const toCloseShortLevels: Array<{ level: number; quantity: number }> = [];
for (const [level, quantity] of this.shortExposure) {
if (quantity <= EPSILON) continue;
const target = this.levelMeta[level]?.closeTarget;
if (target == null) continue;
const closePrice = this.gridLevels[target]!;
if (referencePrice <= closePrice + this.config.priceTick / 2) {
toCloseShortLevels.push({ level, quantity });
}
}
const totalLongClose = toCloseLongLevels.reduce((acc, item) => acc + item.quantity, 0);
const totalShortClose = toCloseShortLevels.reduce((acc, item) => acc + item.quantity, 0);
if (totalLongClose > EPSILON) {
try {
await placeMarketOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pendings,
"SELL",
totalLongClose,
this.log,
true,
{ expectedPrice: referencePrice },
{ qtyStep: this.config.qtyStep }
);
for (const { level } of toCloseLongLevels) {
this.longExposure.delete(level);
}
this.schedulePersist();
} catch (error) {
this.log("error", `市价平仓多单失败: ${extractMessage(error)}`);
}
}
if (totalShortClose > EPSILON) {
try {
await placeMarketOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pendings,
"BUY",
totalShortClose,
this.log,
true,
{ expectedPrice: referencePrice },
{ qtyStep: this.config.qtyStep }
);
for (const { level } of toCloseShortLevels) {
this.shortExposure.delete(level);
}
this.schedulePersist();
} catch (error) {
this.log("error", `市价平仓空单失败: ${extractMessage(error)}`);
}
}
}
private schedulePersist(): void {
if (this.statePersistTimer) return;
this.statePersistTimer = setTimeout(() => {
this.statePersistTimer = null;
void this.persistState();
}, 200);
}
private async persistState(): Promise<void> {
if (!this.configValid) return;
const snapshot: StoredGridState = {
symbol: this.config.symbol,
lowerPrice: this.config.lowerPrice,
upperPrice: this.config.upperPrice,
gridLevels: this.config.gridLevels,
orderSize: this.config.orderSize,
maxPositionSize: this.config.maxPositionSize,
direction: this.config.direction,
longExposure: Object.fromEntries([...this.longExposure.entries()].map(([level, qty]) => [String(level), qty])),
shortExposure: Object.fromEntries([...this.shortExposure.entries()].map(([level, qty]) => [String(level), qty])),
updatedAt: Date.now(),
};
try {
await saveGridState(snapshot);
} catch (error) {
this.log("error", `保存网格状态失败: ${extractMessage(error)}`);
}
}
private async restoreState(): Promise<void> {
try {
const snapshot = await loadGridState(this.config.symbol);
if (!snapshot) return;
if (!this.isSnapshotCompatible(snapshot)) return;
this.longExposure.clear();
this.shortExposure.clear();
for (const [key, value] of Object.entries(snapshot.longExposure ?? {})) {
const level = Number(key);
if (!Number.isInteger(level)) continue;
if (!this.buyLevelIndices.includes(level)) continue;
if (value > EPSILON) this.longExposure.set(level, value);
}
for (const [key, value] of Object.entries(snapshot.shortExposure ?? {})) {
const level = Number(key);
if (!Number.isInteger(level)) continue;
if (!this.sellLevelIndices.includes(level)) continue;
if (value > EPSILON) this.shortExposure.set(level, value);
}
} catch (error) {
this.log("error", `读取网格状态失败: ${extractMessage(error)}`);
}
}
private isSnapshotCompatible(snapshot: StoredGridState): boolean {
const sameSymbol = snapshot.symbol === this.config.symbol;
const sameLevels = snapshot.gridLevels === this.config.gridLevels;
const sameRange =
Math.abs(snapshot.lowerPrice - this.config.lowerPrice) <= this.config.priceTick &&
Math.abs(snapshot.upperPrice - this.config.upperPrice) <= this.config.priceTick;
const sameOrder = Math.abs(snapshot.orderSize - this.config.orderSize) <= this.config.qtyStep;
return sameSymbol && sameLevels && sameRange && sameOrder;
}
private computeGridLevels(): number[] {
if (!this.configValid) return [];
const { lowerPrice, upperPrice, gridLevels } = this.config;
@@ -764,6 +933,7 @@ export class GridEngine {
remaining -= qty;
}
}
this.schedulePersist();
}
const actualShort = Math.max(-this.position.positionAmt, 0);
@@ -779,6 +949,7 @@ export class GridEngine {
remaining -= qty;
}
}
this.schedulePersist();
}
}
@@ -786,16 +957,32 @@ export class GridEngine {
if (quantity <= EPSILON) return;
const current = this.longExposure.get(level) ?? 0;
const next = Math.min(this.config.orderSize, current + quantity);
if (next <= EPSILON) this.longExposure.delete(level);
else this.longExposure.set(level, next);
if (next <= EPSILON) {
if (this.longExposure.has(level)) {
this.longExposure.delete(level);
this.schedulePersist();
}
return;
}
if (Math.abs(next - current) <= EPSILON) return;
this.longExposure.set(level, next);
this.schedulePersist();
}
private incrementShortExposure(level: number, quantity: number): void {
if (quantity <= EPSILON) return;
const current = this.shortExposure.get(level) ?? 0;
const next = Math.min(this.config.orderSize, current + quantity);
if (next <= EPSILON) this.shortExposure.delete(level);
else this.shortExposure.set(level, next);
if (next <= EPSILON) {
if (this.shortExposure.has(level)) {
this.shortExposure.delete(level);
this.schedulePersist();
}
return;
}
if (Math.abs(next - current) <= EPSILON) return;
this.shortExposure.set(level, next);
this.schedulePersist();
}
private consumeLongExposure(closeLevel: number, quantity: number): void {
@@ -811,6 +998,7 @@ export class GridEngine {
if (next <= EPSILON) this.longExposure.delete(source);
else this.longExposure.set(source, next);
remaining -= consumed;
this.schedulePersist();
}
}
@@ -827,6 +1015,7 @@ export class GridEngine {
if (next <= EPSILON) this.shortExposure.delete(source);
else this.shortExposure.set(source, next);
remaining -= consumed;
this.schedulePersist();
}
}
+1
View File
@@ -145,6 +145,7 @@ describe("GridEngine", () => {
restartTriggerPct: 0.01,
autoRestart: true,
gridMode: "geometric",
maxCloseSlippagePct: 0.05,
};
it("creates geometric desired orders when running in both directions", async () => {