feat: 更新网格引擎,重构订单管理逻辑,添加持仓水平和关闭目标管理,优化订单计算和曝光对齐处理

This commit is contained in:
discountry
2025-10-07 03:24:09 +08:00
parent ccb23ecf44
commit 5878a3dc0b
2 changed files with 295 additions and 123 deletions
+247 -122
View File
@@ -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<number, number>();
private readonly lastOrderBook = new Map<string, { side: "BUY" | "SELL"; level: number; quantity: number }>();
private readonly levelMeta: LevelMeta[] = [];
private readonly buyLevelIndices: number[] = [];
private readonly sellLevelIndices: number[] = [];
private readonly longExposure = new Map<number, number>();
private readonly shortExposure = new Map<number, number>();
private readonly lastOrderBook = new Map<
string,
{ side: "BUY" | "SELL"; level: number; quantity: number; reduceOnly: boolean }
>();
private readonly pendingCancelKeys = new Set<string>();
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<string, AsterOrder>();
const orderBookEntries = new Map<string, { side: "BUY" | "SELL"; level: number; quantity: number }>();
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<number, number>();
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<number, number>();
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<string, { side: "BUY" | "SELL"; level: number; quantity: number }>): 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<string, { side: "BUY" | "SELL"; level: number; quantity: number; reduceOnly: boolean }>
): 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, number>): number {
let total = 0;
for (const value of storage.values()) {
total += value;
}
return total;
}
}
+48 -1
View File
@@ -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 });