diff --git a/.env.example b/.env.example index d3b71f4..494bc63 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/grid-trading.md b/grid-trading.md index 99e35d5..e9b7852 100644 --- a/grid-trading.md +++ b/grid-trading.md @@ -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`。 ## 中断恢复行为 diff --git a/src/config.ts b/src/config.ts index 7c7aeac..d4b5ece 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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); diff --git a/src/strategy/common/grid-storage.ts b/src/strategy/common/grid-storage.ts new file mode 100644 index 0000000..8f9240c --- /dev/null +++ b/src/strategy/common/grid-storage.ts @@ -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; + shortExposure: Record; + updatedAt: number; +} + +type GridStateMap = Record; + +async function ensureDataDir(): Promise { + try { + await fs.mkdir(DATA_DIR, { recursive: true }); + } catch { + // ignore + } +} + +async function readStateFile(): Promise { + 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 { + const map = await readStateFile(); + const snapshot = map[symbol]; + return snapshot ?? null; +} + +export async function saveGridState(snapshot: StoredGridState): Promise { + await ensureDataDir(); + const map = await readStateFile(); + map[snapshot.symbol] = snapshot; + await fs.writeFile(GRID_FILE, JSON.stringify(map, null, 2), "utf8"); +} diff --git a/src/strategy/grid-engine.ts b/src/strategy/grid-engine.ts index e35426c..dba1a62 100644 --- a/src/strategy/grid-engine.ts +++ b/src/strategy/grid-engine.ts @@ -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(); + private statePersistTimer: ReturnType | 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 { @@ -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 { + 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 { + 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 { + 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(); } } diff --git a/tests/grid-engine.test.ts b/tests/grid-engine.test.ts index 871c656..b6c7d69 100644 --- a/tests/grid-engine.test.ts +++ b/tests/grid-engine.test.ts @@ -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 () => {