mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
feat: 添加最大平仓滑点配置,更新网格引擎状态持久化逻辑,优化订单管理和状态恢复功能
This commit is contained in:
@@ -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
@@ -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`。
|
||||
|
||||
## 中断恢复行为
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user