mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
feat: 更新网格引擎,添加现有减仓订单恢复逻辑,优化订单曝光管理和状态同步功能
This commit is contained in:
+191
-2
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user