diff --git a/src/strategy/grid-engine.ts b/src/strategy/grid-engine.ts index 2b1eaf0..dc86042 100644 --- a/src/strategy/grid-engine.ts +++ b/src/strategy/grid-engine.ts @@ -81,6 +81,8 @@ export class GridEngine { private readonly levelExposure = new Map(); private readonly lastOrderBook = new Map(); private readonly pendingCancelKeys = new Set(); + private readonly buyLevelIndices: number[]; + private readonly sellLevelIndices: number[]; private accountSnapshot: AsterAccountSnapshot | null = null; private depthSnapshot: AsterDepth | null = null; @@ -119,6 +121,23 @@ export class GridEngine { this.now = options.now ?? Date.now; this.configValid = this.validateConfig(); this.gridLevels = this.computeGridLevels(); + const pivotIndex = Math.floor(Math.max(this.gridLevels.length - 1, 0) / 2); + this.buyLevelIndices = []; + this.sellLevelIndices = []; + for (let i = 0; i < this.gridLevels.length; i += 1) { + if (i <= pivotIndex) { + this.buyLevelIndices.push(i); + } + if (i >= pivotIndex + 1) { + this.sellLevelIndices.push(i); + } + } + if (!this.sellLevelIndices.length && this.gridLevels.length > 1) { + const highest = this.gridLevels.length - 1; + if (!this.sellLevelIndices.includes(highest)) { + this.sellLevelIndices.push(highest); + } + } this.running = this.configValid; if (!this.configValid) { this.stopReason = "配置无效,已暂停网格"; @@ -470,12 +489,12 @@ export class GridEngine { let availableToBuy = Math.max(-this.position.positionAmt, 0); const halfTick = this.config.priceTick / 2; - const belowPrice = this.gridLevels - .map((levelPrice, level) => ({ level, levelPrice })) + const belowPrice = this.buyLevelIndices + .map((level) => ({ level, levelPrice: this.gridLevels[level]! })) .filter(({ levelPrice }) => levelPrice < price - halfTick) .sort((a, b) => b.levelPrice - a.levelPrice); - const abovePrice = this.gridLevels - .map((levelPrice, level) => ({ level, levelPrice })) + const abovePrice = this.sellLevelIndices + .map((level) => ({ level, levelPrice: this.gridLevels[level]! })) .filter(({ levelPrice }) => levelPrice > price + halfTick) .sort((a, b) => a.levelPrice - b.levelPrice); @@ -505,6 +524,10 @@ export class GridEngine { for (const { level, levelPrice } of abovePrice) { const amount = this.config.orderSize; const reduceOnly = this.config.direction === "long"; + const heldLong = this.levelExposure.get(level) ?? 0; + if (!reduceOnly && heldLong > EPSILON) { + continue; + } if (!reduceOnly) { if (remainingShortHeadroom < amount - EPSILON) break; remainingShortHeadroom -= amount; @@ -554,7 +577,8 @@ export class GridEngine { const gridLines: GridLineSnapshot[] = this.gridLevels.map((price, level) => { const desired = this.desiredOrders.find((order) => order.level === level); - const side = desired?.side ?? (price < (lastPrice ?? price) ? "BUY" : "SELL"); + const defaultSide = this.buyLevelIndices.includes(level) ? "BUY" : "SELL"; + const side = desired?.side ?? defaultSide; const key = desired ? this.getOrderKey(desired.side, desired.price) : null; const hasOrder = key ? openOrderKeys.has(key) : false; const active = Boolean(desired && key && desiredKeys.has(key)); diff --git a/tests/grid-engine.test.ts b/tests/grid-engine.test.ts index 4449e86..2e89fe5 100644 --- a/tests/grid-engine.test.ts +++ b/tests/grid-engine.test.ts @@ -219,6 +219,28 @@ describe("GridEngine", () => { engine.stop(); }); + it("keeps level side assignments stable regardless of price", () => { + const adapter = new StubAdapter(); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0 }); + + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); + adapter.emitOrders([]); + + const desiredHigh = (engine as any).computeDesiredOrders(2.45) as Array<{ level: number; side: string }>; + expect(desiredHigh.every((order) => { + const isBuyLevel = order.level <= Math.floor((baseConfig.gridLevels - 1) / 2); + return isBuyLevel ? order.side === "BUY" : order.side === "SELL"; + })).toBe(true); + + const desiredLow = (engine as any).computeDesiredOrders(1.55) as Array<{ level: number; side: string }>; + expect(desiredLow.every((order) => { + const isBuyLevel = order.level <= Math.floor((baseConfig.gridLevels - 1) / 2); + return isBuyLevel ? order.side === "BUY" : order.side === "SELL"; + })).toBe(true); + + engine.stop(); + }); + it("halts the grid and closes positions when stop loss triggers", async () => { const adapter = new StubAdapter(); const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });