feat: 更新网格引擎,增强订单意图管理与消失订单处理逻辑,添加入口与出口意图支持以优化订单分类

This commit is contained in:
discountry
2025-10-08 02:48:06 +08:00
parent bb072b2d1d
commit de8a793bd5
+95 -127
View File
@@ -22,6 +22,7 @@ interface DesiredGridOrder {
side: "BUY" | "SELL"; side: "BUY" | "SELL";
price: string; price: string;
amount: number; amount: number;
intent?: "ENTRY" | "EXIT";
} }
interface LevelMeta { interface LevelMeta {
@@ -92,6 +93,8 @@ export class GridEngine {
private readonly closeKeyBySourceLevel = new Map<number, string>(); private readonly closeKeyBySourceLevel = new Map<number, string>();
private lastKeyMeta = new Map<string, { side: "BUY" | "SELL"; level: number }>(); private lastKeyMeta = new Map<string, { side: "BUY" | "SELL"; level: number }>();
private lastOrderIdsByKey = new Map<string, Set<string>>(); private lastOrderIdsByKey = new Map<string, Set<string>>();
private prevActiveIds: Set<string> = new Set<string>();
private orderIntentById = new Map<string, { side: "BUY" | "SELL"; price: string; level: number; intent: "ENTRY" | "EXIT" }>();
// When an order at a level disappears but account delta hasn't arrived yet, // When an order at a level disappears but account delta hasn't arrived yet,
// temporarily block re-opening at that level to avoid immediate re-placement. // temporarily block re-opening at that level to avoid immediate re-placement.
private readonly levelReopenBlockUntil = new Map<number, number>(); private readonly levelReopenBlockUntil = new Map<number, number>();
@@ -460,117 +463,75 @@ export class GridEngine {
allOrdersById.set(String(o.orderId), o); allOrdersById.set(String(o.orderId), o);
} }
// Detect fills by comparing previous keys vs current snapshot // Build active order key counts with intent awareness
const prevKeys = this.lastOpenOrderKeys;
const prevMeta = this.lastKeyMeta;
const currentKeys = new Set<string>();
const keyToMeta = new Map<string, { side: "BUY" | "SELL"; level: number }>();
const currentOrderIdsByKey = new Map<string, Set<string>>();
for (const o of activeOrders) {
const key = this.getOrderKey(o.side, this.normalizePrice(o.price));
currentKeys.add(key);
const level = this.resolveLevelIndex(Number(o.price));
if (level != null) {
keyToMeta.set(key, { side: o.side, level });
}
const id = String(o.orderId);
const set = currentOrderIdsByKey.get(key) ?? new Set<string>();
set.add(id);
currentOrderIdsByKey.set(key, set);
}
// Build active order key counts (open and reduce-only) for placement and disappearance handling
const activeKeyCounts = new Map<string, number>(); const activeKeyCounts = new Map<string, number>();
const currIds = new Set<string>();
for (const o of activeOrders) { for (const o of activeOrders) {
const k = this.getOrderKey(o.side, this.normalizePrice(o.price)); const id = String(o.orderId);
currIds.add(id);
const meta = this.orderIntentById.get(id);
if (!meta) continue; // unknown intent from legacy/startup orders
const k = this.getOrderKey(o.side, this.normalizePrice(o.price), meta.intent);
activeKeyCounts.set(k, (activeKeyCounts.get(k) ?? 0) + 1); activeKeyCounts.set(k, (activeKeyCounts.get(k) ?? 0) + 1);
} }
for (const prevKey of prevKeys) { // Detect disappeared orders by id
if (currentKeys.has(prevKey)) continue; const disappeared: string[] = [];
const meta = prevMeta.get(prevKey); for (const id of this.prevActiveIds) {
// First, check if this missing key corresponds to a tracked close order if (!currIds.has(id)) disappeared.push(id);
let handledByCloseTracking = false; }
for (const [source, closeKey] of this.closeKeyBySourceLevel.entries()) {
if (closeKey === prevKey) { // Handle disappeared orders classification and reactions
handledByCloseTracking = true; for (const id of disappeared) {
const absNow = Math.abs(this.position.positionAmt); const meta = this.orderIntentById.get(id);
if (absNow + EPSILON < this.lastAbsPositionAmt) { if (!meta) continue;
// filled: clear pending mapping // Classify by position delta or final observable status
this.pendingLongLevels.delete(source); let classified: "filled" | "canceled" | "unknown" = "unknown";
this.pendingShortLevels.delete(source); const rec = allOrdersById.get(id);
this.closeKeyBySourceLevel.delete(source); if (rec) {
} else { const status = String(rec.status || "").toUpperCase();
// canceled: keep pending mapping so desired will re-place close order const executed = Number(rec.executedQty);
} if (["CANCELED", "CANCELLED", "EXPIRED", "REJECTED"].includes(status) && executed <= EPSILON) {
break; classified = "canceled";
} }
} }
if (handledByCloseTracking) continue; if (classified === "unknown") {
// Proactively queue a close at target to safely capture profit if the disappearance was a fill
if (meta) {
const target0 = this.levelMeta[meta.level]?.closeTarget;
if (target0 != null) {
const priceStr0 = this.formatPrice(this.gridLevels[target0]!);
const side0: "BUY" | "SELL" = meta.side === "BUY" ? "SELL" : "BUY";
const key = this.getOrderKey(side0, priceStr0);
const count = activeKeyCounts.get(key) ?? 0;
if (count < 1) {
this.immediateCloseToPlace.push({ sourceLevel: meta.level, targetLevel: target0, side: side0, price: priceStr0 });
}
this.closeKeyBySourceLevel.set(meta.level, key);
}
}
// Use order records to classify disappearance: filled vs canceled
if (meta) {
const prevIds = this.lastOrderIdsByKey.get(prevKey);
let classified: "filled" | "canceled" | "unknown" = "unknown";
// We generally cannot see final states for disappeared orders in openOrders; rely on position if available
const absNow = Math.abs(this.position.positionAmt); const absNow = Math.abs(this.position.positionAmt);
if (absNow > this.lastAbsPositionAmt + EPSILON) { if (absNow > this.lastAbsPositionAmt + EPSILON) classified = "filled";
classified = "filled"; else classified = "canceled"; // best-effort default if no exposure change
} else if (prevIds && prevIds.size) { }
// If any explicit canceled-like status is observable (rare in open snapshot), allow classification if (classified === "filled") {
for (const id of prevIds) { if (meta.intent === "ENTRY") {
const rec = allOrdersById.get(id);
if (!rec) continue;
const status = String(rec.status || "").toUpperCase();
const executed = Number(rec.executedQty);
if (["CANCELED", "CANCELLED", "EXPIRED", "REJECTED"].includes(status) && executed <= EPSILON) {
classified = "canceled";
}
}
}
if (classified === "filled") {
if (meta.side === "BUY") this.pendingLongLevels.add(meta.level); if (meta.side === "BUY") this.pendingLongLevels.add(meta.level);
else this.pendingShortLevels.add(meta.level); else this.pendingShortLevels.add(meta.level);
// ensure reduce-only close mapping exists for disappearance tracking (already queued above) // Immediately plan EXIT for the mapped target
} else if (classified === "canceled") { const target0 = this.levelMeta[meta.level]?.closeTarget;
// no action; allow immediate re-open (do not block) if (target0 != null) {
this.awaitingClassification.delete(meta.level); const priceStr0 = this.formatPrice(this.gridLevels[target0]!);
this.levelReopenBlockUntil.delete(meta.level); const side0: "BUY" | "SELL" = meta.side === "BUY" ? "SELL" : "BUY";
// clear any close mapping queued earlier const exitKey = this.getOrderKey(side0, priceStr0, "EXIT");
const target = this.levelMeta[meta.level]?.closeTarget; const count = activeKeyCounts.get(exitKey) ?? 0;
if (target != null) { if (count < 1) {
const priceStr = this.formatPrice(this.gridLevels[target]!); this.immediateCloseToPlace.push({ sourceLevel: meta.level, targetLevel: target0, side: side0, price: priceStr0 });
const side: "BUY" | "SELL" = meta.side === "BUY" ? "SELL" : "BUY";
const closeKey = this.getOrderKey(side, priceStr);
if (this.closeKeyBySourceLevel.get(meta.level) === closeKey) {
this.closeKeyBySourceLevel.delete(meta.level);
} }
this.closeKeyBySourceLevel.set(meta.level, exitKey);
} }
} else { } else {
// unknown: defer decision until we see a newer account snapshot // EXIT filled
this.awaitingClassification.set(meta.level, { accountVerAtStart: this.accountVersion, absAtStart: this.lastAbsPositionAmt }); this.pendingLongLevels.delete(meta.level);
this.blockLevelReopen(meta.level); this.pendingShortLevels.delete(meta.level);
this.closeKeyBySourceLevel.delete(meta.level);
} }
} else if (classified === "canceled") {
// allow re-open; clear temporary blocks
this.awaitingClassification.delete(meta.level);
this.levelReopenBlockUntil.delete(meta.level);
// if it was EXIT we also drop mapping
if (meta.intent === "EXIT") this.closeKeyBySourceLevel.delete(meta.level);
} }
} }
this.lastOpenOrderKeys = currentKeys; // Update prevActiveIds for next tick
// persist metadata for next tick to detect fills this.prevActiveIds = currIds;
this.lastKeyMeta = keyToMeta;
this.lastOrderIdsByKey = currentOrderIdsByKey;
// Resolve awaiting classifications only after a new account snapshot // Resolve awaiting classifications only after a new account snapshot
if (this.awaitingClassification.size) { if (this.awaitingClassification.size) {
@@ -596,18 +557,14 @@ export class GridEngine {
const desiredKeySet = new Set<string>(); const desiredKeySet = new Set<string>();
const plannedKeyCounts = new Map<string, number>(activeKeyCounts); const plannedKeyCounts = new Map<string, number>(activeKeyCounts);
const halfTick = this.config.priceTick / 2; const halfTick = this.config.priceTick / 2;
const activeKeySet = new Set(
activeOrders.map((o) => this.getOrderKey(o.side, this.normalizePrice(o.price)))
);
// activeKeyCounts and activeKeyCountsRO already computed above
// First, place any immediate close orders queued by fresh fills // First, place any immediate close (EXIT) orders queued by fresh fills
if (this.immediateCloseToPlace.length) { if (this.immediateCloseToPlace.length) {
for (const item of this.immediateCloseToPlace) { for (const item of this.immediateCloseToPlace) {
const key = this.getOrderKey(item.side, item.price); const key = this.getOrderKey(item.side, item.price, "EXIT");
const count = plannedKeyCounts.get(key) ?? 0; const count = plannedKeyCounts.get(key) ?? 0;
if (count < 1 && !desiredKeySet.has(key)) { if (count < 1 && !desiredKeySet.has(key)) {
desired.push({ level: item.targetLevel, side: item.side, price: item.price, amount: this.config.orderSize }); desired.push({ level: item.targetLevel, side: item.side, price: item.price, amount: this.config.orderSize, intent: "EXIT" });
desiredKeySet.add(key); desiredKeySet.add(key);
plannedKeyCounts.set(key, count + 1); plannedKeyCounts.set(key, count + 1);
} }
@@ -619,46 +576,44 @@ export class GridEngine {
this.immediateCloseToPlace = []; this.immediateCloseToPlace = [];
} }
// opens below price (BUY) // ENTRY opens below price (BUY)
for (const level of this.buyLevelIndices) { for (const level of this.buyLevelIndices) {
const levelPrice = this.gridLevels[level]!; const levelPrice = this.gridLevels[level]!;
if (levelPrice >= price - halfTick) continue; if (levelPrice >= price - halfTick) continue;
if (this.isLevelTemporarilyBlocked(level)) continue; // skip during cooldown if (this.isLevelTemporarilyBlocked(level)) continue; // skip during cooldown
if (this.pendingLongLevels.has(level)) continue; // wait until close filled if (this.pendingLongLevels.has(level)) continue; // wait until close filled
const key = this.getOrderKey("BUY", this.formatPrice(levelPrice)); const key = this.getOrderKey("BUY", this.formatPrice(levelPrice), "ENTRY");
const targetMax = this.isCloseDesiredForSideAtLevel("BUY", level) ? 2 : 1; if ((plannedKeyCounts.get(key) ?? 0) >= 1) continue;
if ((plannedKeyCounts.get(key) ?? 0) >= targetMax) continue;
if (!desiredKeySet.has(key)) { if (!desiredKeySet.has(key)) {
desired.push({ level, side: "BUY", price: this.formatPrice(levelPrice), amount: this.config.orderSize }); desired.push({ level, side: "BUY", price: this.formatPrice(levelPrice), amount: this.config.orderSize, intent: "ENTRY" });
desiredKeySet.add(key); desiredKeySet.add(key);
plannedKeyCounts.set(key, (plannedKeyCounts.get(key) ?? 0) + 1); plannedKeyCounts.set(key, (plannedKeyCounts.get(key) ?? 0) + 1);
} }
} }
// opens above price (SELL) // ENTRY opens above price (SELL)
for (const level of this.sellLevelIndices) { for (const level of this.sellLevelIndices) {
const levelPrice = this.gridLevels[level]!; const levelPrice = this.gridLevels[level]!;
if (levelPrice <= price + halfTick) continue; if (levelPrice <= price + halfTick) continue;
if (this.isLevelTemporarilyBlocked(level)) continue; // skip during cooldown if (this.isLevelTemporarilyBlocked(level)) continue; // skip during cooldown
if (this.pendingShortLevels.has(level)) continue; if (this.pendingShortLevels.has(level)) continue;
const key = this.getOrderKey("SELL", this.formatPrice(levelPrice)); const key = this.getOrderKey("SELL", this.formatPrice(levelPrice), "ENTRY");
const targetMax = this.isCloseDesiredForSideAtLevel("SELL", level) ? 2 : 1; if ((plannedKeyCounts.get(key) ?? 0) >= 1) continue;
if ((plannedKeyCounts.get(key) ?? 0) >= targetMax) continue;
if (!desiredKeySet.has(key)) { if (!desiredKeySet.has(key)) {
desired.push({ level, side: "SELL", price: this.formatPrice(levelPrice), amount: this.config.orderSize }); desired.push({ level, side: "SELL", price: this.formatPrice(levelPrice), amount: this.config.orderSize, intent: "ENTRY" });
desiredKeySet.add(key); desiredKeySet.add(key);
plannedKeyCounts.set(key, (plannedKeyCounts.get(key) ?? 0) + 1); plannedKeyCounts.set(key, (plannedKeyCounts.get(key) ?? 0) + 1);
} }
} }
// close orders for pending levels (now non-reduce-only) // EXIT close orders for pending levels
for (const source of this.pendingLongLevels) { for (const source of this.pendingLongLevels) {
const target = this.levelMeta[source]?.closeTarget; const target = this.levelMeta[source]?.closeTarget;
if (target == null) continue; if (target == null) continue;
const priceStr = this.formatPrice(this.gridLevels[target]!); const priceStr = this.formatPrice(this.gridLevels[target]!);
const closeKey = this.getOrderKey("SELL", priceStr); const closeKey = this.getOrderKey("SELL", priceStr, "EXIT");
if ((plannedKeyCounts.get(closeKey) ?? 0) < 1 && !desiredKeySet.has(closeKey)) { if ((plannedKeyCounts.get(closeKey) ?? 0) < 1 && !desiredKeySet.has(closeKey)) {
desired.push({ level: target, side: "SELL", price: priceStr, amount: this.config.orderSize }); desired.push({ level: target, side: "SELL", price: priceStr, amount: this.config.orderSize, intent: "EXIT" });
desiredKeySet.add(closeKey); desiredKeySet.add(closeKey);
plannedKeyCounts.set(closeKey, (plannedKeyCounts.get(closeKey) ?? 0) + 1); plannedKeyCounts.set(closeKey, (plannedKeyCounts.get(closeKey) ?? 0) + 1);
} }
@@ -670,9 +625,9 @@ export class GridEngine {
const target = this.levelMeta[source]?.closeTarget; const target = this.levelMeta[source]?.closeTarget;
if (target == null) continue; if (target == null) continue;
const priceStr = this.formatPrice(this.gridLevels[target]!); const priceStr = this.formatPrice(this.gridLevels[target]!);
const closeKey = this.getOrderKey("BUY", priceStr); const closeKey = this.getOrderKey("BUY", priceStr, "EXIT");
if ((plannedKeyCounts.get(closeKey) ?? 0) < 1 && !desiredKeySet.has(closeKey)) { if ((plannedKeyCounts.get(closeKey) ?? 0) < 1 && !desiredKeySet.has(closeKey)) {
desired.push({ level: target, side: "BUY", price: priceStr, amount: this.config.orderSize }); desired.push({ level: target, side: "BUY", price: priceStr, amount: this.config.orderSize, intent: "EXIT" });
desiredKeySet.add(closeKey); desiredKeySet.add(closeKey);
plannedKeyCounts.set(closeKey, (plannedKeyCounts.get(closeKey) ?? 0) + 1); plannedKeyCounts.set(closeKey, (plannedKeyCounts.get(closeKey) ?? 0) + 1);
} }
@@ -684,9 +639,10 @@ export class GridEngine {
// Place desired orders // Place desired orders
this.desiredOrders = desired; this.desiredOrders = desired;
for (const d of desired) { for (const d of desired) {
const key = this.getOrderKey(d.side, d.price); const isClose = d.intent === "EXIT" || (d.side === "SELL" && this.isTargetOfPendingLong(d.level)) || (d.side === "BUY" && this.isTargetOfPendingShort(d.level));
const isClose = (d.side === "SELL" && this.isTargetOfPendingLong(d.level)) || (d.side === "BUY" && this.isTargetOfPendingShort(d.level)); const intent: "ENTRY" | "EXIT" = isClose ? "EXIT" : "ENTRY";
if ((activeKeyCounts.get(key) ?? 0) >= 1) continue; const key = this.getOrderKey(d.side, d.price, intent);
if ((plannedKeyCounts.get(key) ?? 0) >= 1) continue;
try { try {
const placed = await placeOrder( const placed = await placeOrder(
this.exchange, this.exchange,
@@ -704,7 +660,10 @@ export class GridEngine {
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep, skipDedupe: true } { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep, skipDedupe: true }
); );
if (placed) { if (placed) {
activeKeyCounts.set(key, (activeKeyCounts.get(key) ?? 0) + 1); plannedKeyCounts.set(key, (plannedKeyCounts.get(key) ?? 0) + 1);
if (placed.orderId != null) {
this.orderIntentById.set(String(placed.orderId), { side: d.side, price: d.price, level: d.level, intent });
}
} }
if (placed && isClose) { if (placed && isClose) {
this.closeKeyBySourceLevel.set( this.closeKeyBySourceLevel.set(
@@ -788,19 +747,24 @@ export class GridEngine {
const lastPrice = Number.isFinite(tickerLast) ? tickerLast : reference; const lastPrice = Number.isFinite(tickerLast) ? tickerLast : reference;
const midPrice = reference; const midPrice = reference;
const desiredKeys = new Set( const desiredKeys = new Set(
this.desiredOrders.map((order) => this.getOrderKey(order.side, order.price)) this.desiredOrders.map((order) => this.getOrderKey(order.side, order.price, order.intent ?? "ENTRY"))
); );
const openOrderKeys = new Set( const openOrderKeys = new Set(
this.openOrders this.openOrders
.filter((order) => order.symbol === this.config.symbol && order.type === "LIMIT") .filter((order) => order.symbol === this.config.symbol && order.type === "LIMIT")
.map((order) => this.getOrderKey(order.side, this.normalizePrice(order.price))) .map((order) => {
const id = String(order.orderId);
const meta = this.orderIntentById.get(id);
const intent: "ENTRY" | "EXIT" = meta?.intent ?? "ENTRY";
return this.getOrderKey(order.side, this.normalizePrice(order.price), intent);
})
); );
const gridLines: GridLineSnapshot[] = this.gridLevels.map((price, level) => { const gridLines: GridLineSnapshot[] = this.gridLevels.map((price, level) => {
const desired = this.desiredOrders.find((order) => order.level === level); const desired = this.desiredOrders.find((order) => order.level === level);
const defaultSide = this.buyLevelIndices.includes(level) ? "BUY" : "SELL"; const defaultSide = this.buyLevelIndices.includes(level) ? "BUY" : "SELL";
const side = desired?.side ?? defaultSide; 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.intent ?? "ENTRY") : null;
const hasOrder = key ? openOrderKeys.has(key) : false; const hasOrder = key ? openOrderKeys.has(key) : false;
const active = Boolean(desired && key && desiredKeys.has(key)); const active = Boolean(desired && key && desiredKeys.has(key));
return { return {
@@ -836,8 +800,8 @@ export class GridEngine {
this.events.emit("update", this.buildSnapshot()); this.events.emit("update", this.buildSnapshot());
} }
private getOrderKey(side: "BUY" | "SELL", price: string, reduceOnly = false): string { private getOrderKey(side: "BUY" | "SELL", price: string, intent: "ENTRY" | "EXIT" = "ENTRY"): string {
return `${side}:${price}:${reduceOnly ? "RO" : "OPEN"}`; return `${side}:${price}:${intent}`;
} }
private normalizePrice(price: string | number): string { private normalizePrice(price: string | number): string {
@@ -991,7 +955,11 @@ export class GridEngine {
const source = this.findSourceForInitialPosition(side); const source = this.findSourceForInitialPosition(side);
if (side === "SELL") this.pendingLongLevels.add(source); if (side === "SELL") this.pendingLongLevels.add(source);
else this.pendingShortLevels.add(source); else this.pendingShortLevels.add(source);
this.closeKeyBySourceLevel.set(source, this.getOrderKey(side, priceStr)); const exitKey = this.getOrderKey(side, priceStr, "EXIT");
this.closeKeyBySourceLevel.set(source, exitKey);
if (placed.orderId != null) {
this.orderIntentById.set(String(placed.orderId), { side, price: priceStr, level: nearest, intent: "EXIT" });
}
this.log("order", `为已有仓位挂出一次性平仓单 ${side} @ ${priceStr}`); this.log("order", `为已有仓位挂出一次性平仓单 ${side} @ ${priceStr}`);
} }
} catch (error) { } catch (error) {