feat: 更新网格引擎,添加现有减仓订单恢复逻辑,优化订单曝光管理和状态同步功能

This commit is contained in:
discountry
2025-10-07 04:41:32 +08:00
parent c6f279c51b
commit 66e1b3e6f5
2 changed files with 252 additions and 6 deletions
+191 -2
View File
@@ -98,6 +98,7 @@ export class GridEngine {
>();
private readonly pendingCancelKeys = new Set<string>();
private statePersistTimer: ReturnType<typeof setTimeout> | 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<void> {
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<string, AsterOrder>();
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<number, { side: "BUY" | "SELL" }>();
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<number, number>();
const reduceBuyByLevel = new Map<number, number>();
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<number, number>): 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<number, number>();
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<number, number>): 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<number, number>();
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<number, number>, 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<number, number>, source: Map<number, number>): 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
+61 -4
View File
@@ -22,6 +22,7 @@ class StubAdapter implements ExchangeAdapter {
public createdOrders: CreateOrderParams[] = [];
public marketOrders: CreateOrderParams[] = [];
public cancelAllCount = 0;
public cancelledOrders: Array<number | string> = [];
supportsTrailingStops(): boolean {
return false;
@@ -91,12 +92,12 @@ class StubAdapter implements ExchangeAdapter {
return order;
}
async cancelOrder(): Promise<void> {
// no-op
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
this.cancelledOrders.push(params.orderId);
}
async cancelOrders(): Promise<void> {
// no-op
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
this.cancelledOrders.push(...params.orderIdList);
}
async cancelAllOrders(): Promise<void> {
@@ -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<number, number> = (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 });