feat: 更新网格引擎,添加等待分类机制以处理账户快照后的订单状态,优化入口数量限制与订单过滤逻辑

This commit is contained in:
discountry
2025-10-08 03:38:21 +08:00
parent 730a75df93
commit f973fe433d
+110 -21
View File
@@ -135,6 +135,7 @@ export class GridEngine {
private stopReason: string | null = null;
private lastUpdated: number | null = null;
private accountVersion = 0;
private awaitingByLevel = new Map<number, { accountVerAtStart: number; absAtStart: number }>();
constructor(private readonly config: GridConfig, private readonly exchange: ExchangeAdapter, options: EngineOptions = {}) {
this.tradeLog = createTradeLog(this.config.maxLogEntries);
@@ -149,6 +150,12 @@ export class GridEngine {
this.stopReason = "配置无效,已暂停网格";
this.log("error", this.stopReason);
}
if (this.gridLevels.length === 0) {
this.running = false;
this.stopReason = `网格价位计算失败,模式不支持或参数无效: ${String(this.config.gridMode)}`;
this.log("error", this.stopReason);
this.emitUpdate();
}
this.bootstrap();
}
@@ -199,6 +206,9 @@ export class GridEngine {
if (!Number.isFinite(this.config.maxPositionSize) || this.config.maxPositionSize <= 0) {
return false;
}
if (!Number.isFinite(this.config.refreshIntervalMs) || this.config.refreshIntervalMs < 100) {
return false;
}
return true;
}
@@ -294,11 +304,17 @@ export class GridEngine {
private synchronizeLocks(orders: AsterOrder[] | null | undefined): void {
const list = Array.isArray(orders) ? orders : [];
const FINAL = new Set(["FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"]);
Object.keys(this.pendings).forEach((type) => {
const pendingId = this.pendings[type];
if (!pendingId) return;
const match = list.find((order) => String(order.orderId) === pendingId);
if (!match || (match.status && match.status !== "NEW")) {
if (!match) {
unlockOperating(this.locks, this.timers, this.pendings, type);
return;
}
const status = String(match.status || "").toUpperCase();
if (FINAL.has(status)) {
unlockOperating(this.locks, this.timers, this.pendings, type);
}
});
@@ -397,6 +413,13 @@ export class GridEngine {
this.running = false;
this.pendingLongLevels.clear();
this.pendingShortLevels.clear();
this.awaitingByLevel.clear();
this.closeKeyBySourceLevel.clear();
this.immediateCloseToPlace = [];
// 仅在不需要自动重启时停止轮询定时器
if (!this.config.autoRestart) {
this.stop();
}
}
private async closePosition(): Promise<void> {
@@ -441,11 +464,14 @@ export class GridEngine {
this.log("info", "价格重新回到网格区间,恢复网格运行");
this.running = true;
this.stopReason = null;
// 重新锚定买卖侧(可选:根据当前价格)
this.sidesLocked = false;
this.tryLockSidesOnce();
this.start();
}
private async syncGridSimple(price: number): Promise<void> {
const activeOrders = this.openOrders.filter((o) => o.symbol === this.config.symbol && o.type === "LIMIT");
const activeOrders = this.openOrders.filter((o) => this.isActiveLimitOrder(o));
// Build lookup for all recent orders by id (including non-active) to read final statuses
const allOrdersById = new Map<string, AsterOrder>();
for (const o of this.openOrders) {
@@ -488,11 +514,14 @@ export class GridEngine {
}
}
if (classified === "unknown") {
const absNow = Math.abs(this.position.positionAmt);
const expectUp = meta.intent === "ENTRY"; // ENTRY should increase abs position; EXIT should decrease
if (expectUp && absNow > this.lastAbsPositionAmt + EPSILON) classified = "filled";
else if (!expectUp && absNow + EPSILON < this.lastAbsPositionAmt) classified = "filled";
else classified = "canceled"; // conservative default
// Defer classification until next account snapshot; block ENTRY on that level in the meantime
const level = meta.intent === "EXIT"
? (meta.sourceLevel ?? this.findSourceForCloseTarget(meta.level, meta.side))
: meta.level;
this.awaitingByLevel.set(level, { accountVerAtStart: this.accountVersion, absAtStart: this.lastAbsPositionAmt });
// skip side effects for this disappeared id in this tick
this.orderIntentById.delete(id);
continue;
}
if (classified === "filled") {
if (meta.intent === "ENTRY") {
@@ -524,10 +553,38 @@ export class GridEngine {
this.closeKeyBySourceLevel.delete(src);
}
}
// cleanup intent map for this disappeared id to avoid leaks
this.orderIntentById.delete(id);
}
// Update prevActiveIds for next tick
this.prevActiveIds = currIds;
// Resolve deferred unknown classifications after a new account snapshot
if (this.awaitingByLevel.size) {
for (const [level, info] of Array.from(this.awaitingByLevel.entries())) {
if (this.accountVersion <= info.accountVerAtStart) continue;
const absNow = Math.abs(this.position.positionAmt);
if (absNow > info.absAtStart + EPSILON) {
// infer ENTRY filled -> mark level pending
const sideAtLevel = this.levelMeta[level]?.side === "BUY" ? "BUY" : "SELL";
if (sideAtLevel === "BUY") this.pendingLongLevels.add(level);
else this.pendingShortLevels.add(level);
this.awaitingByLevel.delete(level);
continue;
}
if (absNow + EPSILON < info.absAtStart) {
// infer EXIT filled -> clear pending and close key for source level
this.pendingLongLevels.delete(level);
this.pendingShortLevels.delete(level);
this.closeKeyBySourceLevel.delete(level);
this.awaitingByLevel.delete(level);
continue;
}
// no abs change after new account snapshot -> treat as canceled/no-op
this.awaitingByLevel.delete(level);
}
}
// Desired open orders according to locked sides
const desired: DesiredGridOrder[] = [];
const desiredKeySet = new Set<string>();
@@ -556,6 +613,7 @@ export class GridEngine {
for (const level of this.buyLevelIndices) {
const levelPrice = this.gridLevels[level]!;
if (levelPrice >= price - halfTick) continue;
if (this.awaitingByLevel.has(level)) continue;
if (this.pendingLongLevels.has(level)) continue; // wait until close filled
const key = this.getOrderKey("BUY", this.formatPrice(levelPrice), "ENTRY");
if ((plannedKeyCounts.get(key) ?? 0) >= 1) continue;
@@ -570,6 +628,7 @@ export class GridEngine {
for (const level of this.sellLevelIndices) {
const levelPrice = this.gridLevels[level]!;
if (levelPrice <= price + halfTick) continue;
if (this.awaitingByLevel.has(level)) continue;
if (this.pendingShortLevels.has(level)) continue;
const key = this.getOrderKey("SELL", this.formatPrice(levelPrice), "ENTRY");
if ((plannedKeyCounts.get(key) ?? 0) >= 1) continue;
@@ -615,12 +674,14 @@ export class GridEngine {
for (const d of desired) {
const isClose = d.intent === "EXIT" || (d.side === "SELL" && this.isTargetOfPendingLong(d.level)) || (d.side === "BUY" && this.isTargetOfPendingShort(d.level));
const intent: "ENTRY" | "EXIT" = isClose ? "EXIT" : "ENTRY";
// Cap EXIT quantity to avoid over-closing and accidental flip
// Cap quantities: EXIT by remaining position; ENTRY by maxPositionSize guard
if (intent === "EXIT") {
const capped = this.capExitQty(d.amount, d.side);
if (capped <= EPSILON) {
continue;
}
if (capped <= EPSILON) continue;
d.amount = capped;
} else {
const capped = this.capEntryQty(d.amount);
if (capped <= EPSILON) continue;
d.amount = capped;
}
const key = this.getOrderKey(d.side, d.price, intent);
@@ -677,8 +738,7 @@ export class GridEngine {
if (absPos <= EPSILON) return 0;
let pendingExitQty = 0;
for (const o of this.openOrders) {
if (o.symbol !== this.config.symbol) continue;
if (o.type !== "LIMIT") continue;
if (!this.isActiveLimitOrder(o)) continue;
if (o.side !== side) continue; // same side as this EXIT order
const meta = this.orderIntentById.get(String(o.orderId));
if (!meta || meta.intent !== "EXIT") continue;
@@ -691,6 +751,26 @@ export class GridEngine {
return Math.min(desiredQty, remain);
}
private estimatePendingEntryQty(): number {
let sum = 0;
for (const o of this.openOrders) {
if (!this.isActiveLimitOrder(o)) continue;
const meta = this.orderIntentById.get(String(o.orderId));
if (!meta || meta.intent !== "ENTRY") continue;
const orig = Number(o.origQty || 0);
const exec = Number(o.executedQty || 0);
sum += Math.max(orig - exec, 0);
}
return sum;
}
private capEntryQty(desiredQty: number): number {
const absPos = Math.abs(this.position.positionAmt);
const pendingEntry = this.estimatePendingEntryQty();
const remain = Math.max(this.config.maxPositionSize - absPos - pendingEntry, 0);
return Math.min(desiredQty, remain);
}
private findSourceForCloseTarget(targetLevel: number, side: "BUY" | "SELL"): number {
// side here is reduce-only side at target level; source is opposite side level which maps to this target
if (side === "SELL") {
@@ -735,8 +815,14 @@ export class GridEngine {
const price = lowerPrice * Math.pow(ratio, i);
levels.push(Number(price.toFixed(this.priceDecimals)));
}
// snap endpoints to exact bounds to avoid drift
if (levels.length) {
levels[0] = Number(lowerPrice.toFixed(this.priceDecimals));
levels[levels.length - 1] = Number(upperPrice.toFixed(this.priceDecimals));
}
return levels;
}
this.log("error", `不支持的网格模式: ${String(this.config.gridMode)}`);
return [];
}
@@ -750,7 +836,7 @@ export class GridEngine {
);
const openOrderKeys = new Set(
this.openOrders
.filter((order) => order.symbol === this.config.symbol && order.type === "LIMIT")
.filter((order) => this.isActiveLimitOrder(order))
.map((order) => {
const id = String(order.orderId);
const meta = this.orderIntentById.get(id);
@@ -784,7 +870,7 @@ export class GridEngine {
midPrice,
gridLines,
desiredOrders: this.desiredOrders.slice(),
openOrders: this.openOrders.filter((order) => order.symbol === this.config.symbol),
openOrders: this.openOrders.filter((order) => this.isActiveLimitOrder(order)),
position: this.position,
running: this.running,
stopReason: this.running ? null : this.stopReason,
@@ -803,6 +889,13 @@ export class GridEngine {
return `${side}:${price}:${intent}`;
}
private isActiveLimitOrder(o: AsterOrder): boolean {
if (o.symbol !== this.config.symbol) return false;
if (o.type !== "LIMIT") return false;
const s = String(o.status || "").toUpperCase();
return !["FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"].includes(s);
}
private normalizePrice(price: string | number): string {
const numeric = Number(price);
if (!Number.isFinite(numeric)) return "0";
@@ -829,15 +922,11 @@ 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" }>();
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";
const anchor = anchorByLevel.get(i);
if (anchor) {
side = anchor.side;
} else if (pivotPrice != null) {
if (pivotPrice != null) {
side = this.gridLevels[i]! <= pivotPrice + EPSILON ? "BUY" : "SELL";
} else {
side = i <= pivotIndex ? "BUY" : "SELL";
@@ -930,7 +1019,7 @@ export class GridEngine {
priceStr,
Math.abs(qty),
this.log,
false,
true,
undefined,
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep, skipDedupe: true }
);