diff --git a/src/strategy/grid-engine.ts b/src/strategy/grid-engine.ts index a385b7a..c3f9adc 100644 --- a/src/strategy/grid-engine.ts +++ b/src/strategy/grid-engine.ts @@ -98,6 +98,7 @@ export class GridEngine { >(); private readonly pendingCancelKeys = new Set(); private statePersistTimer: ReturnType | null = null; + private protectExistingOrdersUntil = 0; private accountSnapshot: AsterAccountSnapshot | null = null; private depthSnapshot: AsterDepth | null = null; @@ -229,6 +230,10 @@ export class GridEngine { ? orders.filter((order) => order.symbol === this.config.symbol) : []; this.synchronizeLocks(orders); + if (!this.feedArrived.orders) { + const bufferMs = Math.max(this.config.refreshIntervalMs * 2, 2000); + this.protectExistingOrdersUntil = Math.max(this.protectExistingOrdersUntil, this.now() + bufferMs); + } if (!this.feedArrived.orders) { this.feedArrived.orders = true; log("info", "订单快照已同步"); @@ -500,11 +505,17 @@ export class GridEngine { } private async syncGrid(price: number): Promise { + const activeOrders = this.openOrders.filter((order) => order.symbol === this.config.symbol && order.type === "LIMIT"); + + // Keep level side assignment consistent with currently open orders to avoid churn on restart + this.buildLevelMeta(price); + + this.backfillExposureFromOpenOrders(activeOrders); + const desired = this.computeDesiredOrders(price); this.desiredOrders = desired; const desiredKeys = new Set(desired.map((order) => this.getOrderKey(order.side, order.price, order.reduceOnly))); - const activeOrders = this.openOrders.filter((order) => order.symbol === this.config.symbol && order.type === "LIMIT"); const orderMap = new Map(); const orderBookEntries = new Map< string, @@ -527,6 +538,13 @@ export class GridEngine { this.updateLevelExposure(orderBookEntries); + // During protection window, treat existing orders as desired to ensure no cancellations + if (this.now() < this.protectExistingOrdersUntil) { + for (const key of orderBookEntries.keys()) { + desiredKeys.add(key); + } + } + for (const order of activeOrders) { const key = this.getOrderKey(order.side, this.normalizePrice(order.price), order.reduceOnly === true); if (desiredKeys.has(key)) continue; @@ -838,6 +856,8 @@ export class GridEngine { if (!this.sellLevelIndices.includes(level)) continue; if (value > EPSILON) this.shortExposure.set(level, value); } + const bufferMs = Math.max(this.config.refreshIntervalMs * 2, 2000); + this.protectExistingOrdersUntil = Math.max(this.protectExistingOrdersUntil, this.now() + bufferMs); } catch (error) { this.log("error", `读取网格状态失败: ${extractMessage(error)}`); } @@ -954,11 +974,25 @@ export class GridEngine { this.sellLevelIndices.length = 0; if (!this.gridLevels.length) return; const pivotIndex = Math.floor(Math.max(this.gridLevels.length - 1, 0) / 2); + const anchorByLevel = new Map(); + if (Array.isArray(this.openOrders) && this.openOrders.length) { + for (const order of this.openOrders) { + if (order.symbol !== this.config.symbol || order.type !== "LIMIT") continue; + const level = this.resolveLevelIndex(Number(order.price)); + if (level == null) continue; + if (!anchorByLevel.has(level)) { + anchorByLevel.set(level, { side: order.side }); + } + } + } const hasReference = Number.isFinite(referencePrice ?? NaN); const pivotPrice = hasReference ? this.clampReferencePrice(Number(referencePrice)) : null; for (let i = 0; i < this.gridLevels.length; i += 1) { let side: "BUY" | "SELL"; - if (pivotPrice != null) { + const anchor = anchorByLevel.get(i); + if (anchor) { + side = anchor.side; + } else if (pivotPrice != null) { side = this.gridLevels[i]! <= pivotPrice + EPSILON ? "BUY" : "SELL"; } else { side = i <= pivotIndex ? "BUY" : "SELL"; @@ -1015,6 +1049,161 @@ export class GridEngine { } } + private backfillExposureFromOpenOrders(activeOrders: AsterOrder[]): void { + if (!activeOrders.length) return; + const reduceSellByLevel = new Map(); + const reduceBuyByLevel = new Map(); + + for (const order of activeOrders) { + if (order.reduceOnly !== true) continue; + const level = this.resolveLevelIndex(Number(order.price)); + if (level == null) continue; + const remaining = Math.max( + 0, + Number(order.origQty ?? 0) - Number(order.executedQty ?? 0) + ); + if (remaining <= EPSILON) continue; + if (order.side === "SELL") { + reduceSellByLevel.set(level, (reduceSellByLevel.get(level) ?? 0) + remaining); + } else if (order.side === "BUY") { + reduceBuyByLevel.set(level, (reduceBuyByLevel.get(level) ?? 0) + remaining); + } + } + + const longChanged = this.rebuildLongExposureFromReduceOrders(reduceSellByLevel); + const shortChanged = this.rebuildShortExposureFromReduceOrders(reduceBuyByLevel); + + if (longChanged || shortChanged) { + this.schedulePersist(); + } + } + + private rebuildLongExposureFromReduceOrders(reduceOrders: Map): boolean { + const actualLong = Math.max(this.position.positionAmt, 0); + if (actualLong <= EPSILON && !reduceOrders.size) { + if (this.longExposure.size) { + this.longExposure.clear(); + return true; + } + return false; + } + + const computed = new Map(); + let remaining = actualLong; + + if (reduceOrders.size) { + const ordered = [...reduceOrders.entries()].sort((a, b) => a[0] - b[0]); + for (const [closeLevel, quantity] of ordered) { + if (remaining <= EPSILON) break; + const meta = this.levelMeta[closeLevel]; + if (!meta || meta.side !== "SELL" || !meta.closeSources.length) continue; + const sources = [...meta.closeSources] + .filter((source) => this.buyLevelIndices.includes(source)) + .sort((a, b) => b - a); + if (!sources.length) continue; + const toAllocate = Math.min(quantity, remaining); + if (toAllocate <= EPSILON) continue; + const allocated = this.distributeExposure(computed, sources, toAllocate); + remaining -= allocated; + } + } + + if (remaining > EPSILON) { + const fallbackSources = [...this.buyLevelIndices].sort((a, b) => b - a); + const assigned = this.distributeExposure(computed, fallbackSources, remaining); + remaining -= assigned; + } + + for (const [level, amount] of computed) { + if (amount <= EPSILON) { + computed.delete(level); + } + } + + return this.replaceExposureMap(this.longExposure, computed); + } + + private rebuildShortExposureFromReduceOrders(reduceOrders: Map): boolean { + const actualShort = Math.max(-this.position.positionAmt, 0); + if (actualShort <= EPSILON && !reduceOrders.size) { + if (this.shortExposure.size) { + this.shortExposure.clear(); + return true; + } + return false; + } + + const computed = new Map(); + let remaining = actualShort; + + if (reduceOrders.size) { + const ordered = [...reduceOrders.entries()].sort((a, b) => a[0] - b[0]); + for (const [closeLevel, quantity] of ordered) { + if (remaining <= EPSILON) break; + const meta = this.levelMeta[closeLevel]; + if (!meta || meta.side !== "BUY" || !meta.closeSources.length) continue; + const sources = [...meta.closeSources] + .filter((source) => this.sellLevelIndices.includes(source)) + .sort((a, b) => a - b); + if (!sources.length) continue; + const toAllocate = Math.min(quantity, remaining); + if (toAllocate <= EPSILON) continue; + const allocated = this.distributeExposure(computed, sources, toAllocate); + remaining -= allocated; + } + } + + if (remaining > EPSILON) { + const fallbackSources = [...this.sellLevelIndices].sort((a, b) => a - b); + const assigned = this.distributeExposure(computed, fallbackSources, remaining); + remaining -= assigned; + } + + for (const [level, amount] of computed) { + if (amount <= EPSILON) { + computed.delete(level); + } + } + + return this.replaceExposureMap(this.shortExposure, computed); + } + + private distributeExposure(target: Map, sources: number[], amount: number): number { + if (amount <= EPSILON) return 0; + let remaining = amount; + let assigned = 0; + for (const level of sources) { + if (remaining <= EPSILON) break; + const current = target.get(level) ?? 0; + const capacity = Math.max(this.config.orderSize - current, 0); + if (capacity <= EPSILON) continue; + const toAssign = Math.min(capacity, remaining); + if (toAssign <= EPSILON) continue; + target.set(level, current + toAssign); + remaining -= toAssign; + assigned += toAssign; + } + return assigned; + } + + private replaceExposureMap(target: Map, source: Map): boolean { + let changed = false; + for (const key of [...target.keys()]) { + if (!source.has(key)) { + target.delete(key); + changed = true; + } + } + for (const [key, value] of source) { + const current = target.get(key) ?? 0; + if (Math.abs(current - value) > EPSILON) { + target.set(key, value); + changed = true; + } + } + return changed; + } + private applyFillDelta( entry: { side: "BUY" | "SELL"; level: number; quantity: number; reduceOnly: boolean }, filled: number diff --git a/tests/grid-engine.test.ts b/tests/grid-engine.test.ts index b6c7d69..2e7c38b 100644 --- a/tests/grid-engine.test.ts +++ b/tests/grid-engine.test.ts @@ -22,6 +22,7 @@ class StubAdapter implements ExchangeAdapter { public createdOrders: CreateOrderParams[] = []; public marketOrders: CreateOrderParams[] = []; public cancelAllCount = 0; + public cancelledOrders: Array = []; supportsTrailingStops(): boolean { return false; @@ -91,12 +92,12 @@ class StubAdapter implements ExchangeAdapter { return order; } - async cancelOrder(): Promise { - // no-op + async cancelOrder(params: { symbol: string; orderId: number | string }): Promise { + this.cancelledOrders.push(params.orderId); } - async cancelOrders(): Promise { - // no-op + async cancelOrders(params: { symbol: string; orderIdList: Array }): Promise { + this.cancelledOrders.push(...params.orderIdList); } async cancelAllOrders(): Promise { @@ -289,6 +290,62 @@ describe("GridEngine", () => { engine.stop(); }); + it("restores exposures from existing reduce-only orders on restart", async () => { + const adapter = new StubAdapter(); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0 }); + + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize * 2)); + + const reduceOrder: AsterOrder = { + orderId: "existing-reduce", + clientOrderId: "existing-reduce", + symbol: baseConfig.symbol, + side: "SELL", + type: "LIMIT", + status: "NEW", + price: baseConfig.upperPrice.toFixed(1), + origQty: (baseConfig.orderSize * 2).toString(), + executedQty: "0", + stopPrice: "0", + time: Date.now(), + updateTime: Date.now(), + reduceOnly: true, + closePosition: false, + }; + + adapter.emitOrders([reduceOrder]); + adapter.emitTicker({ + symbol: baseConfig.symbol, + lastPrice: "150", + openPrice: "150", + highPrice: "150", + lowPrice: "150", + volume: "0", + quoteVolume: "0", + }); + + await (engine as any).syncGrid(150); + + const longExposure: Map = (engine as any).longExposure; + const buyIndices: number[] = (engine as any).buyLevelIndices; + + const totalExposure = [...longExposure.values()].reduce((acc, qty) => acc + qty, 0); + expect(totalExposure).toBeCloseTo(baseConfig.orderSize * 2, 6); + expect(longExposure.get(buyIndices.slice(-1)[0]!)).toBeCloseTo(baseConfig.orderSize, 6); + expect(longExposure.get(buyIndices[0]!)).toBeCloseTo(baseConfig.orderSize, 6); + + const snapshot = engine.getSnapshot(); + const reduceDesired = snapshot.desiredOrders.find( + (order) => order.reduceOnly && order.side === "SELL" + ); + expect(reduceDesired).toBeTruthy(); + expect(reduceDesired!.amount).toBeCloseTo(baseConfig.orderSize * 2, 6); + expect(Number(reduceDesired!.price)).toBeCloseTo(baseConfig.upperPrice, 6); + expect(adapter.cancelledOrders).toHaveLength(0); + + 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 });