From 5878a3dc0bade9cd97a862f58115d0135685e05e Mon Sep 17 00:00:00 2001 From: discountry Date: Tue, 7 Oct 2025 03:24:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E7=BD=91=E6=A0=BC?= =?UTF-8?q?=E5=BC=95=E6=93=8E=EF=BC=8C=E9=87=8D=E6=9E=84=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=8C=81=E4=BB=93=E6=B0=B4=E5=B9=B3=E5=92=8C=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E7=AE=A1=E7=90=86=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E8=AE=A1=E7=AE=97=E5=92=8C=E6=9B=9D=E5=85=89?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/strategy/grid-engine.ts | 369 ++++++++++++++++++++++++------------ tests/grid-engine.test.ts | 49 ++++- 2 files changed, 295 insertions(+), 123 deletions(-) diff --git a/src/strategy/grid-engine.ts b/src/strategy/grid-engine.ts index dc86042..e35426c 100644 --- a/src/strategy/grid-engine.ts +++ b/src/strategy/grid-engine.ts @@ -26,6 +26,14 @@ interface DesiredGridOrder { reduceOnly: boolean; } +interface LevelMeta { + index: number; + price: number; + side: "BUY" | "SELL"; + closeTarget: number | null; + closeSources: number[]; +} + interface GridLineSnapshot { level: number; price: number; @@ -78,11 +86,16 @@ export class GridEngine { private readonly now: () => number; private readonly configValid: boolean; private readonly gridLevels: number[]; - private readonly levelExposure = new Map(); - private readonly lastOrderBook = new Map(); + private readonly levelMeta: LevelMeta[] = []; + private readonly buyLevelIndices: number[] = []; + private readonly sellLevelIndices: number[] = []; + private readonly longExposure = new Map(); + private readonly shortExposure = new Map(); + private readonly lastOrderBook = new Map< + string, + { side: "BUY" | "SELL"; level: number; quantity: number; reduceOnly: boolean } + >(); private readonly pendingCancelKeys = new Set(); - private readonly buyLevelIndices: number[]; - private readonly sellLevelIndices: number[]; private accountSnapshot: AsterAccountSnapshot | null = null; private depthSnapshot: AsterDepth | null = null; @@ -121,23 +134,7 @@ 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.buildLevelMeta(); this.running = this.configValid; if (!this.configValid) { this.stopReason = "配置无效,已暂停网格"; @@ -352,7 +349,8 @@ export class GridEngine { this.desiredOrders = []; this.lastUpdated = this.now(); this.running = false; - this.levelExposure.clear(); + this.longExposure.clear(); + this.shortExposure.clear(); this.lastOrderBook.clear(); this.pendingCancelKeys.clear(); } @@ -406,24 +404,32 @@ export class GridEngine { const desired = this.computeDesiredOrders(price); this.desiredOrders = desired; - const desiredKeys = new Set(desired.map((order) => this.getOrderKey(order.side, order.price))); + 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(); + const orderBookEntries = new Map< + string, + { side: "BUY" | "SELL"; level: number; quantity: number; reduceOnly: boolean } + >(); for (const order of activeOrders) { - const key = this.getOrderKey(order.side, this.normalizePrice(order.price)); + const key = this.getOrderKey(order.side, this.normalizePrice(order.price), order.reduceOnly === true); orderMap.set(key, order); const level = this.resolveLevelIndex(Number(order.price)); if (level != null) { const quantity = Math.max(0, Number(order.origQty) - Number(order.executedQty ?? 0)); - orderBookEntries.set(key, { side: order.side, level, quantity }); + orderBookEntries.set(key, { + side: order.side, + level, + quantity, + reduceOnly: order.reduceOnly === true, + }); } } this.updateLevelExposure(orderBookEntries); for (const order of activeOrders) { - const key = this.getOrderKey(order.side, this.normalizePrice(order.price)); + const key = this.getOrderKey(order.side, this.normalizePrice(order.price), order.reduceOnly === true); if (desiredKeys.has(key)) continue; this.pendingCancelKeys.add(key); await safeCancelOrder( @@ -448,7 +454,7 @@ export class GridEngine { } for (const desiredOrder of desired) { - const key = this.getOrderKey(desiredOrder.side, desiredOrder.price); + const key = this.getOrderKey(desiredOrder.side, desiredOrder.price, desiredOrder.reduceOnly); if (orderMap.has(key)) continue; try { const placed = await placeOrder( @@ -481,12 +487,10 @@ export class GridEngine { if (!this.running || !this.gridLevels.length || !this.configValid) return []; this.alignExposureWithPosition(); const desired: DesiredGridOrder[] = []; - const maxLongExposure = Math.max(this.config.maxPositionSize - Math.max(this.position.positionAmt, 0), 0); - const maxShortExposure = Math.max(this.config.maxPositionSize - Math.max(-this.position.positionAmt, 0), 0); - let remainingLongHeadroom = maxLongExposure; - let remainingShortHeadroom = maxShortExposure; - let availableToSell = Math.max(this.position.positionAmt, 0); - let availableToBuy = Math.max(-this.position.positionAmt, 0); + const totalLongExposure = this.sumExposure(this.longExposure); + const totalShortExposure = this.sumExposure(this.shortExposure); + let remainingLongHeadroom = Math.max(this.config.maxPositionSize - totalLongExposure, 0); + let remainingShortHeadroom = Math.max(this.config.maxPositionSize - totalShortExposure, 0); const halfTick = this.config.priceTick / 2; const belowPrice = this.buyLevelIndices @@ -498,49 +502,73 @@ export class GridEngine { .filter(({ levelPrice }) => levelPrice > price + halfTick) .sort((a, b) => a.levelPrice - b.levelPrice); - for (const { level, levelPrice } of belowPrice) { - const amount = this.config.orderSize; - const reduceOnly = this.config.direction === "short"; - const held = this.levelExposure.get(level) ?? 0; - if (held >= amount - EPSILON) { - continue; - } - if (!reduceOnly) { - if (remainingLongHeadroom < amount - EPSILON) break; - remainingLongHeadroom -= amount; - } else { - if (availableToBuy < amount - EPSILON) continue; - availableToBuy -= amount; - } - desired.push({ - level, - side: "BUY", - price: this.formatPrice(levelPrice), - amount, - reduceOnly, - }); + const longCloseRequirements = new Map(); + for (const [level, exposure] of this.longExposure) { + if (exposure <= EPSILON) continue; + const targetIndex = this.levelMeta[level]?.closeTarget; + if (targetIndex == null) continue; + longCloseRequirements.set(targetIndex, (longCloseRequirements.get(targetIndex) ?? 0) + exposure); } - 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; + const shortCloseRequirements = new Map(); + for (const [level, exposure] of this.shortExposure) { + if (exposure <= EPSILON) continue; + const targetIndex = this.levelMeta[level]?.closeTarget; + if (targetIndex == null) continue; + shortCloseRequirements.set(targetIndex, (shortCloseRequirements.get(targetIndex) ?? 0) + exposure); + } + + if (this.config.direction !== "short") { + for (const { level, levelPrice } of belowPrice) { + if (shortCloseRequirements.has(level)) continue; + const exposure = this.longExposure.get(level) ?? 0; + if (exposure >= this.config.orderSize - EPSILON) continue; + if (remainingLongHeadroom < this.config.orderSize - EPSILON) continue; + desired.push({ + level, + side: "BUY", + price: this.formatPrice(levelPrice), + amount: this.config.orderSize, + reduceOnly: false, + }); + remainingLongHeadroom -= this.config.orderSize; } - if (!reduceOnly) { - if (remainingShortHeadroom < amount - EPSILON) break; - remainingShortHeadroom -= amount; - } else { - if (availableToSell < amount - EPSILON) continue; - availableToSell -= amount; + } + + if (this.config.direction !== "long") { + for (const { level, levelPrice } of abovePrice) { + if (longCloseRequirements.has(level)) continue; + const exposure = this.shortExposure.get(level) ?? 0; + if (exposure >= this.config.orderSize - EPSILON) continue; + if (remainingShortHeadroom < this.config.orderSize - EPSILON) continue; + desired.push({ + level, + side: "SELL", + price: this.formatPrice(levelPrice), + amount: this.config.orderSize, + reduceOnly: false, + }); + remainingShortHeadroom -= this.config.orderSize; } + } + + for (const [level, quantity] of longCloseRequirements) { desired.push({ level, side: "SELL", - price: this.formatPrice(levelPrice), - amount, - reduceOnly, + price: this.formatPrice(this.gridLevels[level]!), + amount: quantity, + reduceOnly: true, + }); + } + + for (const [level, quantity] of shortCloseRequirements) { + desired.push({ + level, + side: "BUY", + price: this.formatPrice(this.gridLevels[level]!), + amount: quantity, + reduceOnly: true, }); } @@ -568,18 +596,20 @@ export class GridEngine { const tickerLast = Number(this.tickerSnapshot?.lastPrice); const lastPrice = Number.isFinite(tickerLast) ? tickerLast : reference; const midPrice = reference; - const desiredKeys = new Set(this.desiredOrders.map((order) => this.getOrderKey(order.side, order.price))); + const desiredKeys = new Set( + this.desiredOrders.map((order) => this.getOrderKey(order.side, order.price, order.reduceOnly)) + ); const openOrderKeys = new Set( this.openOrders .filter((order) => order.symbol === this.config.symbol && order.type === "LIMIT") - .map((order) => this.getOrderKey(order.side, this.normalizePrice(order.price))) + .map((order) => this.getOrderKey(order.side, this.normalizePrice(order.price), order.reduceOnly === true)) ); const gridLines: GridLineSnapshot[] = this.gridLevels.map((price, level) => { const desired = this.desiredOrders.find((order) => order.level === level); const defaultSide = this.buyLevelIndices.includes(level) ? "BUY" : "SELL"; const side = desired?.side ?? defaultSide; - const key = desired ? this.getOrderKey(desired.side, desired.price) : null; + const key = desired ? this.getOrderKey(desired.side, desired.price, desired.reduceOnly) : null; const hasOrder = key ? openOrderKeys.has(key) : false; const active = Boolean(desired && key && desiredKeys.has(key)); return { @@ -616,8 +646,8 @@ export class GridEngine { this.events.emit("update", this.buildSnapshot()); } - private getOrderKey(side: "BUY" | "SELL", price: string): string { - return `${side}:${price}`; + private getOrderKey(side: "BUY" | "SELL", price: string, reduceOnly = false): string { + return `${side}:${price}:${reduceOnly ? "RO" : "OPEN"}`; } private normalizePrice(price: string | number): string { @@ -640,22 +670,51 @@ export class GridEngine { return null; } - private updateLevelExposure(currentOrders: Map): void { - const previousEntries = new Map(this.lastOrderBook); - for (const [key, previous] of previousEntries) { + private buildLevelMeta(): void { + this.levelMeta.length = 0; + this.buyLevelIndices.length = 0; + this.sellLevelIndices.length = 0; + if (!this.gridLevels.length) return; + const pivotIndex = Math.floor(Math.max(this.gridLevels.length - 1, 0) / 2); + for (let i = 0; i < this.gridLevels.length; i += 1) { + const side: "BUY" | "SELL" = i <= pivotIndex ? "BUY" : "SELL"; + const meta: LevelMeta = { + index: i, + price: this.gridLevels[i]!, + side, + closeTarget: null, + closeSources: [], + }; + this.levelMeta.push(meta); + if (side === "BUY") this.buyLevelIndices.push(i); + else this.sellLevelIndices.push(i); + } + for (const meta of this.levelMeta) { + if (meta.side === "BUY") { + const target = this.levelMeta.find((candidate) => candidate.index > meta.index && candidate.side === "SELL"); + meta.closeTarget = target?.index ?? null; + if (target) target.closeSources.push(meta.index); + } else { + for (let j = meta.index - 1; j >= 0; j -= 1) { + if (this.levelMeta[j]!.side === "BUY") { + meta.closeTarget = this.levelMeta[j]!.index; + this.levelMeta[j]!.closeSources.push(meta.index); + break; + } + } + } + } + } + + private updateLevelExposure( + currentOrders: Map + ): void { + for (const [key, previous] of this.lastOrderBook) { const current = currentOrders.get(key); if (current) { const delta = previous.quantity - current.quantity; if (Math.abs(delta) > EPSILON) { - if (previous.side === "BUY") { - const held = this.levelExposure.get(previous.level) ?? 0; - this.levelExposure.set(previous.level, held + Math.max(0, delta)); - } else { - const held = this.levelExposure.get(previous.level) ?? 0; - const next = held - Math.max(0, delta); - if (next <= EPSILON) this.levelExposure.delete(previous.level); - else this.levelExposure.set(previous.level, next); - } + this.applyFillDelta(previous, delta); } continue; } @@ -663,18 +722,7 @@ export class GridEngine { this.pendingCancelKeys.delete(key); continue; } - if (previous.quantity <= 0) { - continue; - } - if (previous.side === "BUY") { - const held = this.levelExposure.get(previous.level) ?? 0; - this.levelExposure.set(previous.level, held + previous.quantity); - } else { - const held = this.levelExposure.get(previous.level) ?? 0; - const next = held - previous.quantity; - if (next <= EPSILON) this.levelExposure.delete(previous.level); - else this.levelExposure.set(previous.level, next); - } + this.applyFillDelta(previous, previous.quantity); } this.lastOrderBook.clear(); for (const [key, entry] of currentOrders) { @@ -682,34 +730,111 @@ export class GridEngine { } } - private alignExposureWithPosition(): void { - const totalHeld = Array.from(this.levelExposure.values()).reduce((acc, qty) => acc + qty, 0); - const actualLong = Math.max(this.position.positionAmt, 0); - if (Math.abs(totalHeld - actualLong) <= EPSILON) return; - if (actualLong <= EPSILON) { - this.levelExposure.clear(); + private applyFillDelta( + entry: { side: "BUY" | "SELL"; level: number; quantity: number; reduceOnly: boolean }, + filled: number + ): void { + if (filled <= EPSILON) return; + if (entry.reduceOnly) { + if (entry.side === "SELL") { + this.consumeLongExposure(entry.level, filled); + } else { + this.consumeShortExposure(entry.level, filled); + } return; } - let remaining = actualLong; - const levels = Array.from(this.levelExposure.keys()).sort((a, b) => a - b); - for (const level of levels) { - if (remaining <= EPSILON) { - this.levelExposure.delete(level); - continue; - } - const current = this.levelExposure.get(level) ?? 0; - if (current <= remaining + EPSILON) { - this.levelExposure.set(level, current); - remaining -= current; - } else { - this.levelExposure.set(level, remaining); - remaining = 0; + if (entry.side === "BUY") { + this.incrementLongExposure(entry.level, filled); + } else { + this.incrementShortExposure(entry.level, filled); + } + } + + private alignExposureWithPosition(): void { + const actualLong = Math.max(this.position.positionAmt, 0); + const trackedLong = this.sumExposure(this.longExposure); + if (Math.abs(trackedLong - actualLong) > EPSILON) { + this.longExposure.clear(); + let remaining = actualLong; + for (const level of [...this.buyLevelIndices].sort((a, b) => b - a)) { + if (remaining <= EPSILON) break; + const qty = Math.min(this.config.orderSize, remaining); + if (qty > EPSILON) { + this.longExposure.set(level, qty); + remaining -= qty; + } } } - for (const [level, qty] of this.levelExposure) { - if (qty <= EPSILON) { - this.levelExposure.delete(level); + + const actualShort = Math.max(-this.position.positionAmt, 0); + const trackedShort = this.sumExposure(this.shortExposure); + if (Math.abs(trackedShort - actualShort) > EPSILON) { + this.shortExposure.clear(); + let remaining = actualShort; + for (const level of [...this.sellLevelIndices].sort((a, b) => a - b)) { + if (remaining <= EPSILON) break; + const qty = Math.min(this.config.orderSize, remaining); + if (qty > EPSILON) { + this.shortExposure.set(level, qty); + remaining -= qty; + } } } } + + private incrementLongExposure(level: number, quantity: number): void { + 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); + } + + 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); + } + + private consumeLongExposure(closeLevel: number, quantity: number): void { + if (quantity <= EPSILON) return; + const sources = this.levelMeta[closeLevel]?.closeSources ?? []; + let remaining = quantity; + for (const source of [...sources].sort((a, b) => b - a)) { + if (remaining <= EPSILON) break; + const current = this.longExposure.get(source) ?? 0; + if (current <= EPSILON) continue; + const consumed = Math.min(current, remaining); + const next = current - consumed; + if (next <= EPSILON) this.longExposure.delete(source); + else this.longExposure.set(source, next); + remaining -= consumed; + } + } + + private consumeShortExposure(closeLevel: number, quantity: number): void { + if (quantity <= EPSILON) return; + const sources = this.levelMeta[closeLevel]?.closeSources ?? []; + let remaining = quantity; + for (const source of [...sources].sort((a, b) => a - b)) { + if (remaining <= EPSILON) break; + const current = this.shortExposure.get(source) ?? 0; + if (current <= EPSILON) continue; + const consumed = Math.min(current, remaining); + const next = current - consumed; + if (next <= EPSILON) this.shortExposure.delete(source); + else this.shortExposure.set(source, next); + remaining -= consumed; + } + } + + private sumExposure(storage: Map): number { + let total = 0; + for (const value of storage.values()) { + total += value; + } + return total; + } } diff --git a/tests/grid-engine.test.ts b/tests/grid-engine.test.ts index 2e89fe5..871c656 100644 --- a/tests/grid-engine.test.ts +++ b/tests/grid-engine.test.ts @@ -206,7 +206,7 @@ describe("GridEngine", () => { expect(nearestBuy).toBeTruthy(); const targetLevel = nearestBuy!.level; - (engine as any).levelExposure.set(targetLevel, baseConfig.orderSize); + (engine as any).longExposure.set(targetLevel, baseConfig.orderSize); adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize)); const desiredAfterFill = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string }>; @@ -241,6 +241,53 @@ describe("GridEngine", () => { engine.stop(); }); + it("limits active sell orders by remaining short headroom", () => { + const adapter = new StubAdapter(); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0 }); + + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); + adapter.emitOrders([]); + + const desiredFull = (engine as any).computeDesiredOrders(2.1) as Array<{ level: number; side: string }>; + const sellCountFull = desiredFull.filter((order) => order.side === "SELL").length; + expect(sellCountFull).toBeGreaterThan(0); + + const limitedHeadroomConfig = { ...baseConfig, maxPositionSize: baseConfig.orderSize * 2 }; + const limitedEngine = new GridEngine(limitedHeadroomConfig, adapter as any, { now: () => 0 }); + (limitedEngine as any).shortExposure.set(12, baseConfig.orderSize * 2); + + const desiredLimited = (limitedEngine as any).computeDesiredOrders(2.1) as Array<{ level: number; side: string }>; + const sellCountLimited = desiredLimited.filter((order) => order.side === "SELL").length; + expect(sellCountLimited).toBeLessThanOrEqual(1); + + engine.stop(); + limitedEngine.stop(); + }); + + it("places reduce-only orders to close existing exposures", () => { + const adapter = new StubAdapter(); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0 }); + + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize)); + adapter.emitOrders([]); + + const buyLevel = (engine as any).buyLevelIndices.slice(-1)[0]; + (engine as any).longExposure.set(buyLevel, baseConfig.orderSize); + + const desired = (engine as any).computeDesiredOrders(2.05) as Array<{ + level: number; + side: string; + reduceOnly: boolean; + amount: number; + }>; + + const closeOrder = desired.find((order) => order.reduceOnly && order.side === "SELL"); + expect(closeOrder).toBeTruthy(); + expect(closeOrder!.amount).toBeCloseTo(baseConfig.orderSize); + + 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 });