mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 09:18:08 +00:00
feat: 更新网格引擎,重构订单管理逻辑,添加持仓水平和关闭目标管理,优化订单计算和曝光对齐处理
This commit is contained in:
+247
-122
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user