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; reduceOnly: boolean;
} }
interface LevelMeta {
index: number;
price: number;
side: "BUY" | "SELL";
closeTarget: number | null;
closeSources: number[];
}
interface GridLineSnapshot { interface GridLineSnapshot {
level: number; level: number;
price: number; price: number;
@@ -78,11 +86,16 @@ export class GridEngine {
private readonly now: () => number; private readonly now: () => number;
private readonly configValid: boolean; private readonly configValid: boolean;
private readonly gridLevels: number[]; private readonly gridLevels: number[];
private readonly levelExposure = new Map<number, number>(); private readonly levelMeta: LevelMeta[] = [];
private readonly lastOrderBook = new Map<string, { side: "BUY" | "SELL"; level: number; quantity: number }>(); 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 pendingCancelKeys = new Set<string>();
private readonly buyLevelIndices: number[];
private readonly sellLevelIndices: number[];
private accountSnapshot: AsterAccountSnapshot | null = null; private accountSnapshot: AsterAccountSnapshot | null = null;
private depthSnapshot: AsterDepth | null = null; private depthSnapshot: AsterDepth | null = null;
@@ -121,23 +134,7 @@ export class GridEngine {
this.now = options.now ?? Date.now; this.now = options.now ?? Date.now;
this.configValid = this.validateConfig(); this.configValid = this.validateConfig();
this.gridLevels = this.computeGridLevels(); this.gridLevels = this.computeGridLevels();
const pivotIndex = Math.floor(Math.max(this.gridLevels.length - 1, 0) / 2); this.buildLevelMeta();
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.running = this.configValid; this.running = this.configValid;
if (!this.configValid) { if (!this.configValid) {
this.stopReason = "配置无效,已暂停网格"; this.stopReason = "配置无效,已暂停网格";
@@ -352,7 +349,8 @@ export class GridEngine {
this.desiredOrders = []; this.desiredOrders = [];
this.lastUpdated = this.now(); this.lastUpdated = this.now();
this.running = false; this.running = false;
this.levelExposure.clear(); this.longExposure.clear();
this.shortExposure.clear();
this.lastOrderBook.clear(); this.lastOrderBook.clear();
this.pendingCancelKeys.clear(); this.pendingCancelKeys.clear();
} }
@@ -406,24 +404,32 @@ export class GridEngine {
const desired = this.computeDesiredOrders(price); const desired = this.computeDesiredOrders(price);
this.desiredOrders = desired; 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 activeOrders = this.openOrders.filter((order) => order.symbol === this.config.symbol && order.type === "LIMIT");
const orderMap = new Map<string, AsterOrder>(); 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) { 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); orderMap.set(key, order);
const level = this.resolveLevelIndex(Number(order.price)); const level = this.resolveLevelIndex(Number(order.price));
if (level != null) { if (level != null) {
const quantity = Math.max(0, Number(order.origQty) - Number(order.executedQty ?? 0)); 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); this.updateLevelExposure(orderBookEntries);
for (const order of activeOrders) { 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; if (desiredKeys.has(key)) continue;
this.pendingCancelKeys.add(key); this.pendingCancelKeys.add(key);
await safeCancelOrder( await safeCancelOrder(
@@ -448,7 +454,7 @@ export class GridEngine {
} }
for (const desiredOrder of desired) { 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; if (orderMap.has(key)) continue;
try { try {
const placed = await placeOrder( const placed = await placeOrder(
@@ -481,12 +487,10 @@ export class GridEngine {
if (!this.running || !this.gridLevels.length || !this.configValid) return []; if (!this.running || !this.gridLevels.length || !this.configValid) return [];
this.alignExposureWithPosition(); this.alignExposureWithPosition();
const desired: DesiredGridOrder[] = []; const desired: DesiredGridOrder[] = [];
const maxLongExposure = Math.max(this.config.maxPositionSize - Math.max(this.position.positionAmt, 0), 0); const totalLongExposure = this.sumExposure(this.longExposure);
const maxShortExposure = Math.max(this.config.maxPositionSize - Math.max(-this.position.positionAmt, 0), 0); const totalShortExposure = this.sumExposure(this.shortExposure);
let remainingLongHeadroom = maxLongExposure; let remainingLongHeadroom = Math.max(this.config.maxPositionSize - totalLongExposure, 0);
let remainingShortHeadroom = maxShortExposure; let remainingShortHeadroom = Math.max(this.config.maxPositionSize - totalShortExposure, 0);
let availableToSell = Math.max(this.position.positionAmt, 0);
let availableToBuy = Math.max(-this.position.positionAmt, 0);
const halfTick = this.config.priceTick / 2; const halfTick = this.config.priceTick / 2;
const belowPrice = this.buyLevelIndices const belowPrice = this.buyLevelIndices
@@ -498,49 +502,73 @@ export class GridEngine {
.filter(({ levelPrice }) => levelPrice > price + halfTick) .filter(({ levelPrice }) => levelPrice > price + halfTick)
.sort((a, b) => a.levelPrice - b.levelPrice); .sort((a, b) => a.levelPrice - b.levelPrice);
for (const { level, levelPrice } of belowPrice) { const longCloseRequirements = new Map<number, number>();
const amount = this.config.orderSize; for (const [level, exposure] of this.longExposure) {
const reduceOnly = this.config.direction === "short"; if (exposure <= EPSILON) continue;
const held = this.levelExposure.get(level) ?? 0; const targetIndex = this.levelMeta[level]?.closeTarget;
if (held >= amount - EPSILON) { if (targetIndex == null) continue;
continue; longCloseRequirements.set(targetIndex, (longCloseRequirements.get(targetIndex) ?? 0) + exposure);
}
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,
});
} }
for (const { level, levelPrice } of abovePrice) { const shortCloseRequirements = new Map<number, number>();
const amount = this.config.orderSize; for (const [level, exposure] of this.shortExposure) {
const reduceOnly = this.config.direction === "long"; if (exposure <= EPSILON) continue;
const heldLong = this.levelExposure.get(level) ?? 0; const targetIndex = this.levelMeta[level]?.closeTarget;
if (!reduceOnly && heldLong > EPSILON) { if (targetIndex == null) continue;
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; if (this.config.direction !== "long") {
} else { for (const { level, levelPrice } of abovePrice) {
if (availableToSell < amount - EPSILON) continue; if (longCloseRequirements.has(level)) continue;
availableToSell -= amount; 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({ desired.push({
level, level,
side: "SELL", side: "SELL",
price: this.formatPrice(levelPrice), price: this.formatPrice(this.gridLevels[level]!),
amount, amount: quantity,
reduceOnly, 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 tickerLast = Number(this.tickerSnapshot?.lastPrice);
const lastPrice = Number.isFinite(tickerLast) ? tickerLast : reference; const lastPrice = Number.isFinite(tickerLast) ? tickerLast : reference;
const midPrice = 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( 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) => this.getOrderKey(order.side, this.normalizePrice(order.price), order.reduceOnly === true))
); );
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.reduceOnly) : 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 {
@@ -616,8 +646,8 @@ export class GridEngine {
this.events.emit("update", this.buildSnapshot()); this.events.emit("update", this.buildSnapshot());
} }
private getOrderKey(side: "BUY" | "SELL", price: string): string { private getOrderKey(side: "BUY" | "SELL", price: string, reduceOnly = false): string {
return `${side}:${price}`; return `${side}:${price}:${reduceOnly ? "RO" : "OPEN"}`;
} }
private normalizePrice(price: string | number): string { private normalizePrice(price: string | number): string {
@@ -640,22 +670,51 @@ export class GridEngine {
return null; return null;
} }
private updateLevelExposure(currentOrders: Map<string, { side: "BUY" | "SELL"; level: number; quantity: number }>): void { private buildLevelMeta(): void {
const previousEntries = new Map(this.lastOrderBook); this.levelMeta.length = 0;
for (const [key, previous] of previousEntries) { 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); const current = currentOrders.get(key);
if (current) { if (current) {
const delta = previous.quantity - current.quantity; const delta = previous.quantity - current.quantity;
if (Math.abs(delta) > EPSILON) { if (Math.abs(delta) > EPSILON) {
if (previous.side === "BUY") { this.applyFillDelta(previous, delta);
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);
}
} }
continue; continue;
} }
@@ -663,18 +722,7 @@ export class GridEngine {
this.pendingCancelKeys.delete(key); this.pendingCancelKeys.delete(key);
continue; continue;
} }
if (previous.quantity <= 0) { this.applyFillDelta(previous, previous.quantity);
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.lastOrderBook.clear(); this.lastOrderBook.clear();
for (const [key, entry] of currentOrders) { for (const [key, entry] of currentOrders) {
@@ -682,34 +730,111 @@ export class GridEngine {
} }
} }
private alignExposureWithPosition(): void { private applyFillDelta(
const totalHeld = Array.from(this.levelExposure.values()).reduce((acc, qty) => acc + qty, 0); entry: { side: "BUY" | "SELL"; level: number; quantity: number; reduceOnly: boolean },
const actualLong = Math.max(this.position.positionAmt, 0); filled: number
if (Math.abs(totalHeld - actualLong) <= EPSILON) return; ): void {
if (actualLong <= EPSILON) { if (filled <= EPSILON) return;
this.levelExposure.clear(); if (entry.reduceOnly) {
if (entry.side === "SELL") {
this.consumeLongExposure(entry.level, filled);
} else {
this.consumeShortExposure(entry.level, filled);
}
return; return;
} }
let remaining = actualLong; if (entry.side === "BUY") {
const levels = Array.from(this.levelExposure.keys()).sort((a, b) => a - b); this.incrementLongExposure(entry.level, filled);
for (const level of levels) { } else {
if (remaining <= EPSILON) { this.incrementShortExposure(entry.level, filled);
this.levelExposure.delete(level); }
continue; }
}
const current = this.levelExposure.get(level) ?? 0; private alignExposureWithPosition(): void {
if (current <= remaining + EPSILON) { const actualLong = Math.max(this.position.positionAmt, 0);
this.levelExposure.set(level, current); const trackedLong = this.sumExposure(this.longExposure);
remaining -= current; if (Math.abs(trackedLong - actualLong) > EPSILON) {
} else { this.longExposure.clear();
this.levelExposure.set(level, remaining); let remaining = actualLong;
remaining = 0; 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) { const actualShort = Math.max(-this.position.positionAmt, 0);
this.levelExposure.delete(level); 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(); expect(nearestBuy).toBeTruthy();
const targetLevel = nearestBuy!.level; 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)); adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize));
const desiredAfterFill = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string }>; const desiredAfterFill = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string }>;
@@ -241,6 +241,53 @@ describe("GridEngine", () => {
engine.stop(); 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 () => { it("halts the grid and closes positions when stop loss triggers", async () => {
const adapter = new StubAdapter(); const adapter = new StubAdapter();
const engine = new GridEngine(baseConfig, adapter, { now: () => 0 }); const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });