From 345ad11ba33954bbd7f28e1ea27f571422e61ea1 Mon Sep 17 00:00:00 2001 From: discountry Date: Mon, 6 Apr 2026 20:05:03 +0800 Subject: [PATCH] refactor: enhance order handling and tracking in GridEngine - Introduced a clientOrderId system for better order identification. - Updated order creation logic to ensure unique clientOrderIds. - Improved order cancellation methods to maintain accurate current orders. - Added tests for new clientOrderId functionality and level state tracking. - Ensured that desired orders have an intent field set for clarity. - Enhanced snapshot functionality to include level states for grid lines. --- src/core/order-coordinator.ts | 2 + src/exchanges/aster/gateway.ts | 1 + src/exchanges/binance/gateway.ts | 3 + src/exchanges/dry-run-adapter.ts | 4 +- src/exchanges/order-handlers.ts | 3 + src/exchanges/order-schema.ts | 1 + src/exchanges/types.ts | 1 + src/strategy/common/grid-storage.ts | 17 +- src/strategy/grid-engine.ts | 1210 +++++++++++++++------------ tests/grid-engine.test.ts | 375 ++++++++- 10 files changed, 1067 insertions(+), 550 deletions(-) diff --git a/src/core/order-coordinator.ts b/src/core/order-coordinator.ts index ec0d04f..da8720d 100644 --- a/src/core/order-coordinator.ts +++ b/src/core/order-coordinator.ts @@ -134,6 +134,7 @@ type PlaceOrderOptions = { skipDedupe?: boolean; slPrice?: number; tpPrice?: number; + clientOrderId?: string; }; export async function placeOrder( @@ -180,6 +181,7 @@ export async function placeOrder( closePosition, slPrice: opts?.slPrice, tpPrice: opts?.tpPrice, + clientOrderId: opts?.clientOrderId, }); pendings[type] = String(order.orderId); log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}${opts?.slPrice ? ` sl=${opts.slPrice}` : ""}`); diff --git a/src/exchanges/aster/gateway.ts b/src/exchanges/aster/gateway.ts index c9a57f0..1b046c9 100644 --- a/src/exchanges/aster/gateway.ts +++ b/src/exchanges/aster/gateway.ts @@ -815,6 +815,7 @@ export class AsterRestClient { if (params.activationPrice !== undefined) payload.activationPrice = params.activationPrice; if (params.callbackRate !== undefined) payload.callbackRate = params.callbackRate; if (params.quantity !== undefined) payload.quantity = Math.abs(params.quantity); + if (params.clientOrderId !== undefined) payload.newClientOrderId = params.clientOrderId; // Aster rejects reduceOnly/closePosition for certain order types (e.g. STOP/TRAILING). // Keep the behavior exchange-specific by stripping them here for Aster. diff --git a/src/exchanges/binance/gateway.ts b/src/exchanges/binance/gateway.ts index 4366a28..3e6699f 100644 --- a/src/exchanges/binance/gateway.ts +++ b/src/exchanges/binance/gateway.ts @@ -487,6 +487,9 @@ export class BinanceGateway { if (params.callbackRate != null) { extra.callbackRate = params.callbackRate; } + if (params.clientOrderId != null) { + extra.newClientOrderId = params.clientOrderId; + } if (market.kind === "perp") { if (params.reduceOnly != null) { diff --git a/src/exchanges/dry-run-adapter.ts b/src/exchanges/dry-run-adapter.ts index 71e84ab..a2d2ec0 100644 --- a/src/exchanges/dry-run-adapter.ts +++ b/src/exchanges/dry-run-adapter.ts @@ -130,10 +130,10 @@ export class DryRunExchangeAdapter implements ExchangeAdapter { function createSyntheticOrder(params: CreateOrderParams, counter: number): Order { const now = Date.now(); - const orderId = `dry-run-${now}-${counter}`; + const orderId = params.clientOrderId ?? `dry-run-${now}-${counter}`; return { orderId, - clientOrderId: orderId, + clientOrderId: params.clientOrderId ?? orderId, symbol: params.symbol, side: params.side, type: params.type, diff --git a/src/exchanges/order-handlers.ts b/src/exchanges/order-handlers.ts index 975e7ce..b7ae7d0 100644 --- a/src/exchanges/order-handlers.ts +++ b/src/exchanges/order-handlers.ts @@ -45,6 +45,9 @@ function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): if (intent.closePosition !== undefined) { params.closePosition = toStringBoolean(intent.closePosition); } + if (intent.clientOrderId !== undefined) { + params.clientOrderId = intent.clientOrderId; + } return params; } diff --git a/src/exchanges/order-schema.ts b/src/exchanges/order-schema.ts index 75cc3f2..69fda6f 100644 --- a/src/exchanges/order-schema.ts +++ b/src/exchanges/order-schema.ts @@ -9,6 +9,7 @@ export interface BaseOrderIntent { reduceOnly?: boolean; closePosition?: boolean; timeInForce?: TimeInForce | "GTX"; + clientOrderId?: string; } export interface LimitOrderIntent extends BaseOrderIntent { diff --git a/src/exchanges/types.ts b/src/exchanges/types.ts index b281984..9845ff3 100644 --- a/src/exchanges/types.ts +++ b/src/exchanges/types.ts @@ -27,6 +27,7 @@ export interface CreateOrderParams { triggerType?: "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS"; slPrice?: number; tpPrice?: number; + clientOrderId?: string; } export interface AccountPosition { diff --git a/src/strategy/common/grid-storage.ts b/src/strategy/common/grid-storage.ts index 023bb87..ea7f275 100644 --- a/src/strategy/common/grid-storage.ts +++ b/src/strategy/common/grid-storage.ts @@ -5,6 +5,19 @@ import type { GridDirection } from "../../config"; const DATA_DIR = process.env.GRID_DATA_DIR?.trim() || path.resolve("data"); const GRID_FILE = path.resolve(DATA_DIR, "grid-record.json"); +/** State of a single grid level */ +export type LevelState = "idle" | "filled" | "exit_placed"; + +export interface StoredLevelInfo { + state: LevelState; + /** The grid level index where ENTRY was filled */ + sourceLevel: number; + /** The grid level index where EXIT is targeted (closeTarget) */ + targetLevel: number | null; + /** The orderId of the EXIT order on exchange (if exit_placed) */ + exitOrderId?: string; +} + export interface StoredGridState { symbol: string; lowerPrice: number; @@ -13,8 +26,8 @@ export interface StoredGridState { orderSize: number; maxPositionSize: number; direction: GridDirection; - longExposure: Record; - shortExposure: Record; + /** Per-level state: key is level index string */ + levels: Record; updatedAt: number; } diff --git a/src/strategy/grid-engine.ts b/src/strategy/grid-engine.ts index e5d97de..c61f4cd 100644 --- a/src/strategy/grid-engine.ts +++ b/src/strategy/grid-engine.ts @@ -16,13 +16,25 @@ import { } from "../core/order-coordinator"; import { StrategyEventEmitter } from "./common/event-emitter"; import { safeSubscribe, type LogHandler } from "./common/subscriptions"; +import { + loadGridState, + saveGridState, + clearGridState, + type StoredGridState, + type StoredLevelInfo, + type LevelState, +} from "./common/grid-storage"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- interface DesiredGridOrder { level: number; side: "BUY" | "SELL"; price: string; amount: number; - intent?: "ENTRY" | "EXIT"; + intent: "ENTRY" | "EXIT"; reduceOnly?: boolean; } @@ -40,6 +52,7 @@ interface GridLineSnapshot { side: "BUY" | "SELL"; active: boolean; hasOrder: boolean; + state: LevelState; } export interface GridEngineSnapshot { @@ -71,10 +84,58 @@ type GridListener = (snapshot: GridEngineSnapshot) => void; interface EngineOptions { now?: () => number; + /** Skip disk persistence (for tests) */ + skipPersistence?: boolean; } +// --------------------------------------------------------------------------- +// clientOrderId encoding/decoding +// --------------------------------------------------------------------------- + +const CID_PREFIX = "grid"; + +/** ENTRY: grid-E-{level}-{tsHex} EXIT: grid-X-{sourceLevel}-{targetLevel}-{tsHex} */ +function makeClientOrderId(intent: "ENTRY" | "EXIT", level: number, targetOrSource?: number): string { + const hex = Date.now().toString(16); + if (intent === "ENTRY") return `${CID_PREFIX}-E-${level}-${hex}`; + return `${CID_PREFIX}-X-${targetOrSource ?? 0}-${level}-${hex}`; +} + +interface ParsedClientOrderId { + intent: "ENTRY" | "EXIT"; + level: number; + sourceLevel?: number; +} + +function parseClientOrderId(cid: string): ParsedClientOrderId | null { + if (!cid || !cid.startsWith(`${CID_PREFIX}-`)) return null; + const parts = cid.split("-"); + // grid-E-{level}-{hex} + if (parts[1] === "E" && parts.length >= 3) { + const level = Number(parts[2]); + if (!Number.isFinite(level)) return null; + return { intent: "ENTRY", level }; + } + // grid-X-{sourceLevel}-{targetLevel}-{hex} + if (parts[1] === "X" && parts.length >= 4) { + const sourceLevel = Number(parts[2]); + const targetLevel = Number(parts[3]); + if (!Number.isFinite(sourceLevel) || !Number.isFinite(targetLevel)) return null; + return { intent: "EXIT", level: targetLevel, sourceLevel }; + } + return null; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + const EPSILON = 1e-8; +// --------------------------------------------------------------------------- +// GridEngine +// --------------------------------------------------------------------------- + export class GridEngine { private readonly tradeLog: ReturnType; private readonly events = new StrategyEventEmitter(); @@ -88,33 +149,35 @@ export class GridEngine { private readonly levelMeta: LevelMeta[] = []; private readonly buyLevelIndices: number[] = []; private readonly sellLevelIndices: number[] = []; - - private readonly pendingLongLevels = new Set(); - private readonly pendingShortLevels = new Set(); - private readonly closeKeyBySourceLevel = new Map(); - // Legacy compatibility maps kept for tests/debugging. - private readonly longExposure = new Map(); - private readonly shortExposure = new Map(); - - private prevActiveIds: Set = new Set(); - private orderIntentById = new Map(); - // 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. - - // Track levels awaiting classification after disappearance until next account snapshot confirms - - // Key-level suppression for (side:price:intent) to bridge WS latency windows + private readonly skipPersistence: boolean; + + // --- Per-level state tracking --- + // Each grid level can be: idle → filled → exit_placed → idle (cycle) + // A level in "filled" or "exit_placed" state CANNOT accept a new ENTRY order. + private readonly levelStates = new Map(); + // Maps source level → target level for EXIT orders + private readonly exitTargetBySource = new Map(); + // Maps order id → parsed intent for tracking active orders + private readonly orderIntentById = new Map(); + + // Deferred disappearance classification + private readonly awaitingByLevel = new Map(); + + // Order key suppression to bridge WS latency private readonly pendingKeyUntil = new Map(); static readonly PENDING_TTL_MS = 10_000; + private prevActiveIds = new Set(); private sidesLocked = false; - private startupCleaned = false; - private startupCancelDone = false; - private startupCancelPromise: Promise | null = null; - private initialCloseHandled = false; + private recoveryDone = false; + private recoveryPromise: Promise | null = null; private lastAbsPositionAmt = 0; private immediateCloseToPlace: Array<{ sourceLevel: number; targetLevel: number; side: "BUY" | "SELL"; price: string }> = []; + // Legacy compatibility maps kept for tests calling computeDesiredOrders/syncGrid + private readonly longExposure = new Map(); + private readonly shortExposure = new Map(); + private accountSnapshot: AccountSnapshot | null = null; private depthSnapshot: Depth | null = null; private tickerSnapshot: Ticker | null = null; @@ -147,16 +210,17 @@ export class GridEngine { private lastUpdated: number | null = null; private accountVersion = 0; private ordersVersion = 0; - private awaitingByLevel = new Map(); private lastPlacementOrdersVersion = -1; private lastLimitAttemptAt = 0; static readonly LIMIT_COOLDOWN_MS = 3000; + private savePending = false; constructor(private readonly config: GridConfig, private readonly exchange: ExchangeAdapter, options: EngineOptions = {}) { this.tradeLog = createTradeLog(this.config.maxLogEntries); this.log = (type, detail) => this.tradeLog.push(type, detail); this.priceDecimals = decimalsOf(this.config.priceTick); this.now = options.now ?? Date.now; + this.skipPersistence = options.skipPersistence ?? false; this.configValid = this.validateConfig(); this.gridLevels = this.computeGridLevels(); this.buildLevelMeta(); @@ -172,6 +236,10 @@ export class GridEngine { this.log("error", this.stopReason); this.emitUpdate(); } + // Initialize all levels to idle + for (let i = 0; i < this.gridLevels.length; i++) { + this.levelStates.set(i, "idle"); + } this.bootstrap(); } @@ -206,6 +274,10 @@ export class GridEngine { return this.buildSnapshot(); } + // ----------------------------------------------------------------------- + // Precision sync + // ----------------------------------------------------------------------- + private syncPrecision(): void { if (this.precisionSync) return; const getPrecision = this.exchange.getPrecision?.bind(this.exchange); @@ -228,10 +300,7 @@ export class GridEngine { } } if (updated) { - this.log( - "info", - `已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}` - ); + this.log("info", `已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`); this.rebuildGridAfterPrecisionUpdate(); } }) @@ -249,31 +318,33 @@ export class GridEngine { this.gridLevels.length = 0; this.gridLevels.push(...newLevels); this.buildLevelMeta(reference); + // Re-initialize level states + for (let i = 0; i < this.gridLevels.length; i++) { + if (!this.levelStates.has(i)) { + this.levelStates.set(i, "idle"); + } + } this.emitUpdate(); } + // ----------------------------------------------------------------------- + // Validation + // ----------------------------------------------------------------------- + private validateConfig(): boolean { - if (this.config.lowerPrice <= 0 || this.config.upperPrice <= 0) { - return false; - } - if (this.config.upperPrice <= this.config.lowerPrice) { - return false; - } - if (!Number.isFinite(this.config.gridLevels) || this.config.gridLevels < 2) { - return false; - } - if (!Number.isFinite(this.config.orderSize) || this.config.orderSize <= 0) { - return false; - } - if (!Number.isFinite(this.config.maxPositionSize) || this.config.maxPositionSize <= 0) { - return false; - } - if (!Number.isFinite(this.config.refreshIntervalMs) || this.config.refreshIntervalMs < 1) { - return false; - } + if (this.config.lowerPrice <= 0 || this.config.upperPrice <= 0) return false; + if (this.config.upperPrice <= this.config.lowerPrice) return false; + if (!Number.isFinite(this.config.gridLevels) || this.config.gridLevels < 2) return false; + if (!Number.isFinite(this.config.orderSize) || this.config.orderSize <= 0) return false; + if (!Number.isFinite(this.config.maxPositionSize) || this.config.maxPositionSize <= 0) return false; + if (!Number.isFinite(this.config.refreshIntervalMs) || this.config.refreshIntervalMs < 1) return false; return true; } + // ----------------------------------------------------------------------- + // Bootstrap / Feed subscriptions + // ----------------------------------------------------------------------- + private bootstrap(): void { const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail); @@ -291,7 +362,6 @@ export class GridEngine { } this.feedStatus.account = true; this.tryLockSidesOnce(); - this.tryHandleInitialClose(); this.emitUpdate(); }, log, @@ -312,12 +382,11 @@ export class GridEngine { if (!this.feedArrived.orders) { this.feedArrived.orders = true; log("info", "订单快照已同步"); - // cancel all existing orders at startup per simplified rules - this.startupCancelPromise = this.cancelAllExistingOrdersOnStartup(); + // Trigger recovery from existing orders + persisted state + this.recoveryPromise = this.recoverState(); } this.feedStatus.orders = true; this.tryLockSidesOnce(); - this.tryHandleInitialClose(); this.emitUpdate(); }, log, @@ -355,7 +424,6 @@ export class GridEngine { } this.feedStatus.ticker = true; this.tryLockSidesOnce(); - this.tryHandleInitialClose(); this.emitUpdate(); }, log, @@ -384,23 +452,204 @@ export class GridEngine { }); } + // ----------------------------------------------------------------------- + // Recovery: reconstruct level states from open orders + disk + position + // ----------------------------------------------------------------------- + + private async recoverState(): Promise { + if (this.recoveryDone) return; + this.recoveryDone = true; + + // 1) Load persisted state from disk + let persisted: StoredGridState | null = null; + if (!this.skipPersistence) { + try { + persisted = await loadGridState(this.config.symbol); + } catch (err) { + this.log("error", `加载网格状态失败: ${extractMessage(err)}`); + } + } + + // 2) Check if persisted state matches current config + const configMatch = persisted && + persisted.lowerPrice === this.config.lowerPrice && + persisted.upperPrice === this.config.upperPrice && + persisted.gridLevels === this.config.gridLevels; + + // 3) Restore level states from persisted data if config matches + if (configMatch && persisted) { + let restored = 0; + for (const [key, info] of Object.entries(persisted.levels)) { + const idx = Number(key); + if (!Number.isFinite(idx) || idx < 0 || idx >= this.gridLevels.length) continue; + if (info.state === "filled" || info.state === "exit_placed") { + this.levelStates.set(idx, info.state); + if (info.targetLevel != null) { + this.exitTargetBySource.set(idx, info.targetLevel); + } + restored++; + } + } + if (restored > 0) { + this.log("info", `从磁盘恢复了 ${restored} 条网格等级状态`); + } + } + + // 4) Parse open orders' clientOrderId to reconstruct intent tracking + const activeOrders = this.openOrders.filter(o => this.isActiveLimitOrder(o)); + let recognized = 0; + for (const o of activeOrders) { + const cid = o.clientOrderId; + const parsed = parseClientOrderId(cid); + if (!parsed) continue; + if (parsed.level < 0 || parsed.level >= this.gridLevels.length) continue; + + const id = String(o.orderId); + if (parsed.intent === "ENTRY") { + this.orderIntentById.set(id, { + side: o.side, + price: this.normalizePrice(o.price), + level: parsed.level, + intent: "ENTRY", + }); + } else { + const src = parsed.sourceLevel ?? 0; + this.orderIntentById.set(id, { + side: o.side, + price: this.normalizePrice(o.price), + level: parsed.level, + intent: "EXIT", + sourceLevel: src, + }); + // Source level should be at least "filled" since an EXIT exists for it + if (this.levelStates.get(src) === "idle") { + this.levelStates.set(src, "exit_placed"); + } + this.exitTargetBySource.set(src, parsed.level); + } + recognized++; + } + + // 5) If we have a net position but no level is in filled/exit_placed state, + // infer from position which levels should be marked filled + const absPos = Math.abs(this.position.positionAmt); + if (absPos > EPSILON) { + const filledOrExiting = this.countNonIdleLevels(); + if (filledOrExiting === 0) { + this.inferLevelStatesFromPosition(); + } + } + + // 6) Cancel stale ENTRY orders that don't match any idle level + // (leftover from a crashed prior run with different grid params) + const staleOrderIds: Array = []; + for (const o of activeOrders) { + const cid = o.clientOrderId; + const parsed = parseClientOrderId(cid); + if (!parsed) { + // Unknown order — not placed by this grid engine. Cancel it. + staleOrderIds.push(o.orderId); + continue; + } + if (parsed.intent === "ENTRY") { + const state = this.levelStates.get(parsed.level); + if (state !== "idle") { + // This level is already filled or has an exit; stale ENTRY + staleOrderIds.push(o.orderId); + } + } + } + + if (staleOrderIds.length > 0) { + try { + await this.exchange.cancelOrders({ symbol: this.config.symbol, orderIdList: staleOrderIds }); + this.log("order", `恢复阶段:撤销 ${staleOrderIds.length} 个过时挂单`); + } catch (err) { + this.log("error", `恢复阶段撤单失败: ${extractMessage(err)}`); + } + } + + // Initialize prevActiveIds from current orders to avoid false disappearances on first tick + this.prevActiveIds = new Set( + this.openOrders.filter(o => this.isActiveLimitOrder(o)).map(o => String(o.orderId)) + ); + + if (recognized > 0 || (configMatch && persisted)) { + this.log("info", `恢复完成: 识别 ${recognized} 个订单, 非空闲等级 ${this.countNonIdleLevels()} 个`); + } else { + this.log("info", "无历史状态可恢复,从零开始部网"); + } + + this.emitUpdate(); + } + + private countNonIdleLevels(): number { + let count = 0; + for (const [, state] of this.levelStates) { + if (state !== "idle") count++; + } + return count; + } + + /** When no persisted state but position exists, infer which levels should be "filled" */ + private inferLevelStatesFromPosition(): void { + const qty = this.position.positionAmt; + if (Math.abs(qty) <= EPSILON) return; + const entry = this.position.entryPrice; + + if (qty > 0) { + // Long position — mark nearest BUY levels as filled + let remaining = Math.abs(qty); + const candidates = this.buyLevelIndices.slice().reverse(); + for (const level of candidates) { + if (remaining <= EPSILON) break; + this.levelStates.set(level, "filled"); + const target = this.levelMeta[level]?.closeTarget; + if (target != null) { + this.exitTargetBySource.set(level, target); + } + remaining -= this.config.orderSize; + } + this.log("info", `根据多头仓位推断 ${Math.abs(qty)} 个网格等级为已成交`); + } else { + let remaining = Math.abs(qty); + const candidates = this.sellLevelIndices.slice(); + for (const level of candidates) { + if (remaining <= EPSILON) break; + this.levelStates.set(level, "filled"); + const target = this.levelMeta[level]?.closeTarget; + if (target != null) { + this.exitTargetBySource.set(level, target); + } + remaining -= this.config.orderSize; + } + this.log("info", `根据空头仓位推断 ${Math.abs(qty)} 个网格等级为已成交`); + } + } + + // ----------------------------------------------------------------------- + // Tick loop + // ----------------------------------------------------------------------- + private async tick(): Promise { if (this.processing) return; this.processing = true; try { this.tryLockSidesOnce(); - this.tryHandleInitialClose(); if (!this.running) { await this.tryRestart(); return; } - if (!this.isReady()) { - return; + if (!this.isReady()) return; + // Wait for recovery before grid operations + if (!this.recoveryDone) { + if (this.recoveryPromise) { + try { await this.recoveryPromise; } catch {} + } + if (!this.recoveryDone) return; } const price = this.getReferencePrice(); - if (!Number.isFinite(price) || price === null) { - return; - } + if (!Number.isFinite(price) || price === null) return; if (this.shouldStop(price)) { await this.haltGrid(price); return; @@ -422,7 +671,6 @@ export class GridEngine { return getMidOrLast(this.depthSnapshot, this.tickerSnapshot); } - private tryLockSidesOnce(): void { if (this.sidesLocked) return; if (!this.feedStatus.ticker && !this.feedStatus.depth) return; @@ -441,10 +689,9 @@ export class GridEngine { return Math.min(Math.max(price, minLevel), maxLevel); } - - // removed unused hasActiveOrders - - private deferPositionAlignment(): void {} + // ----------------------------------------------------------------------- + // Stop / Halt / Restart + // ----------------------------------------------------------------------- private shouldStop(price: number): boolean { if (this.config.stopLossPct <= 0) return false; @@ -475,12 +722,18 @@ export class GridEngine { this.desiredOrders = []; this.lastUpdated = this.now(); this.running = false; - this.pendingLongLevels.clear(); - this.pendingShortLevels.clear(); + // Reset all level states + for (const [k] of this.levelStates) { + this.levelStates.set(k, "idle"); + } + this.exitTargetBySource.clear(); this.awaitingByLevel.clear(); - this.closeKeyBySourceLevel.clear(); + this.orderIntentById.clear(); this.immediateCloseToPlace = []; - // 仅在不需要自动重启时停止轮询定时器 + // Clear persisted state + if (!this.skipPersistence) { + try { await clearGridState(this.config.symbol); } catch {} + } if (!this.config.autoRestart) { this.stop(); } @@ -522,110 +775,49 @@ export class GridEngine { if (!Number.isFinite(price) || price === null) return; const lowerGuard = this.config.lowerPrice * (1 + this.config.restartTriggerPct); const upperGuard = this.config.upperPrice * (1 - this.config.restartTriggerPct); - if (price < lowerGuard || price > upperGuard) { - return; - } + if (price < lowerGuard || price > upperGuard) return; this.log("info", "价格重新回到网格区间,恢复网格运行"); this.running = true; this.stopReason = null; - // 重新锚定买卖侧(可选:根据当前价格) this.sidesLocked = false; this.tryLockSidesOnce(); this.start(); } + // ----------------------------------------------------------------------- + // Core grid sync logic + // ----------------------------------------------------------------------- + private async syncGridSimple(price: number): Promise { - // 启动撤单未完成前,禁止铺网/下单,避免新单被启动撤单冲掉造成“消失待判定” - if (!this.startupCancelDone) { - this.log("info", "启动撤单未完成,等待后再部网"); + // Wait for recovery to complete + if (!this.recoveryDone) { + this.log("info", "恢复未完成,等待后再部网"); this.lastUpdated = this.now(); return; } - // --- 0) If there is an existing net position, enforce "exit-first" before any ENTRY --- + + // --- 0) Exit-first: if position exists, ensure at least one EXIT order --- const hasNetLong = this.position.positionAmt > EPSILON; const hasNetShort = this.position.positionAmt < -EPSILON; - const hasActiveExit = (side: "BUY" | "SELL"): boolean => { - for (const o of this.openOrders) { - if (!this.isActiveLimitOrder(o)) continue; - if (o.side !== side) continue; - const meta = this.orderIntentById.get(String(o.orderId)); - if (meta && meta.intent === "EXIT") return true; - } - return false; - }; - - const ensureSingleExitForExistingPosition = async (): Promise => { - const qty = this.position.positionAmt; - if (!Number.isFinite(qty) || Math.abs(qty) <= EPSILON) return false; - const entry = this.position.entryPrice; - if (!Number.isFinite(entry)) return false; - const dir: "long" | "short" = qty > 0 ? "long" : "short"; - const nearest = this.findNearestProfitableCloseLevel(dir, Number(entry)); - if (nearest == null) return false; - const exitSide: "BUY" | "SELL" = qty > 0 ? "SELL" : "BUY"; - const priceStr = this.formatPrice(this.gridLevels[nearest]!); - const key = this.getOrderKey(exitSide, priceStr, "EXIT"); - const activeAlready = hasActiveExit(exitSide); - const until = this.pendingKeyUntil.get(key); - if (activeAlready || (until && until > this.now())) return activeAlready; - try { - const placed = await placeOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pendings, - exitSide, - priceStr, - Math.abs(qty), - this.log, - false, - undefined, - { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep, skipDedupe: true } - ); - this.pendingKeyUntil.set(key, this.now() + GridEngine.PENDING_TTL_MS); - if (placed?.orderId != null) { - const source = this.findSourceForInitialPosition(exitSide); - if (exitSide === "SELL") this.pendingLongLevels.add(source); - else this.pendingShortLevels.add(source); - this.closeKeyBySourceLevel.set(source, key); - this.orderIntentById.set(String(placed.orderId), { - side: exitSide, - price: priceStr, - level: nearest, - intent: "EXIT", - sourceLevel: source, - }); - this.log("order", `兜底:为已有仓位挂平仓单 ${exitSide} @ ${priceStr}`); - } - return true; - } catch (err) { - this.log("error", `兜底平仓单下单失败: ${extractMessage(err)}`); - return false; - } - }; - if (hasNetLong || hasNetShort) { const needExitSide: "BUY" | "SELL" = hasNetLong ? "SELL" : "BUY"; - if (!hasActiveExit(needExitSide)) { - await ensureSingleExitForExistingPosition(); + if (!this.hasActiveExit(needExitSide)) { + await this.ensureExitForPosition(); this.lastUpdated = this.now(); this.prevActiveIds = new Set(this.openOrders.filter(o => this.isActiveLimitOrder(o)).map(o => String(o.orderId))); return; } } - const activeOrders = this.openOrders.filter((o) => this.isActiveLimitOrder(o)); - // Build lookup for all recent orders by id (including non-active) to read final statuses + // --- 1) Classify disappeared orders --- + const activeOrders = this.openOrders.filter(o => this.isActiveLimitOrder(o)); const allOrdersById = new Map(); for (const o of this.openOrders) { if (o.symbol !== this.config.symbol) continue; allOrdersById.set(String(o.orderId), o); } - // Build active order key counts with intent awareness const activeKeyCounts = new Map(); const currIds = new Set(); for (const o of activeOrders) { @@ -637,7 +829,7 @@ export class GridEngine { const k = this.getOrderKey(o.side, priceStr, meta.intent); activeKeyCounts.set(k, (activeKeyCounts.get(k) ?? 0) + 1); } else { - // Conservative: count both ENTRY and EXIT to avoid duplicate placements when intent is unknown + // Unknown order (not placed by this engine) — count conservatively const kEntry = this.getOrderKey(o.side, priceStr, "ENTRY"); const kExit = this.getOrderKey(o.side, priceStr, "EXIT"); activeKeyCounts.set(kEntry, (activeKeyCounts.get(kEntry) ?? 0) + 1); @@ -645,22 +837,23 @@ export class GridEngine { } } - // Any key that is already visible as active should clear local suppression + // Clear suppression for keys that are now visible for (const [k, cnt] of activeKeyCounts.entries()) { if ((cnt ?? 0) > 0) this.pendingKeyUntil.delete(k); } - // Detect disappeared orders by id + // Detect disappeared orders const disappeared: string[] = []; for (const id of this.prevActiveIds) { if (!currIds.has(id)) disappeared.push(id); } - // Handle disappeared orders classification and reactions + let stateChanged = false; + for (const id of disappeared) { const meta = this.orderIntentById.get(id); if (!meta) continue; - // Classify by final observable status or position delta (direction-aware) + let classified: "filled" | "canceled" | "unknown" = "unknown"; const rec = allOrdersById.get(id); if (rec) { @@ -672,62 +865,71 @@ export class GridEngine { classified = "canceled"; } } + if (classified === "unknown") { - // 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.sourceLevel ?? meta.level) : meta.level; - this.awaitingByLevel.set(level, { accountVerAtStart: this.accountVersion, absAtStart: this.lastAbsPositionAmt, ts: this.now() }); - // skip side effects for this disappeared id in this tick + this.awaitingByLevel.set(level, { + accountVerAtStart: this.accountVersion, + absAtStart: this.lastAbsPositionAmt, + ts: this.now(), + }); this.orderIntentById.delete(id); continue; } + if (classified === "filled") { + stateChanged = true; if (meta.intent === "ENTRY") { - if (meta.side === "BUY") this.pendingLongLevels.add(meta.level); - else this.pendingShortLevels.add(meta.level); - // Immediately plan EXIT for the mapped target - 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 exitKey = this.getOrderKey(side0, priceStr0, "EXIT"); + // ENTRY filled → mark level as "filled", queue EXIT + this.levelStates.set(meta.level, "filled"); + const target = this.levelMeta[meta.level]?.closeTarget; + if (target != null) { + this.exitTargetBySource.set(meta.level, target); + const exitSide: "BUY" | "SELL" = meta.side === "BUY" ? "SELL" : "BUY"; + const priceStr = this.formatPrice(this.gridLevels[target]!); + const exitKey = this.getOrderKey(exitSide, priceStr, "EXIT"); const count = activeKeyCounts.get(exitKey) ?? 0; if (count < 1) { - this.immediateCloseToPlace.push({ sourceLevel: meta.level, targetLevel: target0, side: side0, price: priceStr0 }); + this.immediateCloseToPlace.push({ + sourceLevel: meta.level, + targetLevel: target, + side: exitSide, + price: priceStr, + }); } - this.closeKeyBySourceLevel.set(meta.level, exitKey); } + this.log("order", `ENTRY 成交: ${meta.side} @ ${meta.price} (等级 ${meta.level})`); } else { - // EXIT filled -> clear using sourceLevel - const src = meta.sourceLevel ?? this.findSourceForCloseTarget(meta.level, meta.side); - this.pendingLongLevels.delete(src); - this.pendingShortLevels.delete(src); - this.closeKeyBySourceLevel.delete(src); + // EXIT filled → release source level back to idle + const src = meta.sourceLevel ?? meta.level; + this.levelStates.set(src, "idle"); + this.exitTargetBySource.delete(src); + this.log("order", `EXIT 成交: ${meta.side} @ ${meta.price} (释放等级 ${src})`); } - // Clear suppression for this key on fill const filledKey = this.getOrderKey(meta.side, meta.price, meta.intent); this.pendingKeyUntil.delete(filledKey); } else if (classified === "canceled") { - // if it was EXIT we also drop mapping by source + stateChanged = true; if (meta.intent === "EXIT") { - const src = meta.sourceLevel ?? this.findSourceForCloseTarget(meta.level, meta.side); - this.closeKeyBySourceLevel.delete(src); + // EXIT canceled → revert source back to "filled" so a new EXIT can be placed + const src = meta.sourceLevel ?? meta.level; + this.levelStates.set(src, "filled"); + this.exitTargetBySource.delete(src); } - // Clear suppression for this key on cancel + // ENTRY canceled → level stays idle (no change needed) const canceledKey = this.getOrderKey(meta.side, meta.price, meta.intent); this.pendingKeyUntil.delete(canceledKey); } - // 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 + // --- 2) Resolve deferred unknown classifications --- if (this.awaitingByLevel.size) { for (const [level, info] of Array.from(this.awaitingByLevel.entries())) { - // timeout fallback: if no account delta for long time, treat as canceled/no-op if (this.now() - info.ts > 8000) { this.awaitingByLevel.delete(level); continue; @@ -735,41 +937,39 @@ export class GridEngine { 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); + // ENTRY filled + this.levelStates.set(level, "filled"); + const target = this.levelMeta[level]?.closeTarget; + if (target != null) this.exitTargetBySource.set(level, target); + stateChanged = true; 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); + // EXIT filled + this.levelStates.set(level, "idle"); + this.exitTargetBySource.delete(level); + stateChanged = true; this.awaitingByLevel.delete(level); continue; } - // no abs change after new account snapshot -> treat as canceled/no-op + // No change after new account snapshot → treat as canceled this.awaitingByLevel.delete(level); } } - // Desired open orders according to locked sides + // --- 3) Build desired orders --- const desired: DesiredGridOrder[] = []; const desiredKeySet = new Set(); const plannedKeyCounts = new Map(activeKeyCounts); const halfTick = this.config.priceTick / 2; - // First, place any immediate close (EXIT) orders queued by fresh fills + // 3a) Immediate close (EXIT) orders from fresh fills if (this.immediateCloseToPlace.length) { for (const item of this.immediateCloseToPlace) { const key = this.getOrderKey(item.side, item.price, "EXIT"); const until = this.pendingKeyUntil.get(key); - const nowTs = this.now(); - if (until && until > nowTs) { - continue; - } + if (until && until > this.now()) continue; const count = plannedKeyCounts.get(key) ?? 0; if (count < 1 && !desiredKeySet.has(key)) { desired.push({ @@ -778,49 +978,55 @@ export class GridEngine { price: item.price, amount: this.config.orderSize, intent: "EXIT", - reduceOnly: true, }); desiredKeySet.add(key); plannedKeyCounts.set(key, count + 1); } - if (!this.closeKeyBySourceLevel.has(item.sourceLevel)) { - this.closeKeyBySourceLevel.set(item.sourceLevel, key); - } } - // clear queue regardless to avoid duplicates next tick this.immediateCloseToPlace = []; } - // hasNetLong/hasNetShort already computed above for exit-first - - // ENTRY opens below price (BUY) - for (const level of this.buyLevelIndices) { - if (hasNetLong || hasNetShort) { - // During exit-first, do not place any ENTRY orders - continue; + // 3b) EXIT orders for all filled/exit_placed levels that don't already have an active EXIT + for (const [level, state] of this.levelStates) { + if (state !== "filled" && state !== "exit_placed") continue; + const target = this.exitTargetBySource.get(level); + if (target == null) continue; + const meta = this.levelMeta[level]; + if (!meta) continue; + const exitSide: "BUY" | "SELL" = meta.side === "BUY" ? "SELL" : "BUY"; + const priceStr = this.formatPrice(this.gridLevels[target]!); + const closeKey = this.getOrderKey(exitSide, priceStr, "EXIT"); + const until = this.pendingKeyUntil.get(closeKey); + if (until && until > this.now()) continue; + if ((plannedKeyCounts.get(closeKey) ?? 0) < 1 && !desiredKeySet.has(closeKey)) { + desired.push({ + level: target, + side: exitSide, + price: priceStr, + amount: this.config.orderSize, + intent: "EXIT", + }); + desiredKeySet.add(closeKey); + plannedKeyCounts.set(closeKey, (plannedKeyCounts.get(closeKey) ?? 0) + 1); } + } + + // 3c) ENTRY BUY orders below price + for (const level of this.buyLevelIndices) { + // During exit-first phase, skip ENTRY + if (hasNetLong || hasNetShort) continue; + const levelState = this.levelStates.get(level) ?? "idle"; + if (levelState !== "idle") continue; // Level already filled — no re-entry until EXIT fills const levelPrice = this.gridLevels[level]!; if (levelPrice >= price - halfTick) continue; - if (this.awaitingByLevel.has(level)) { - this.log("info", `跳过 BUY @ ${this.formatPrice(levelPrice)}:等待上一笔消失判定`); - continue; - } - if (this.pendingLongLevels.has(level)) { - this.log("info", `跳过 BUY @ ${this.formatPrice(levelPrice)}:等待对应平仓成交`); - continue; // wait until close filled - } + if (this.awaitingByLevel.has(level)) continue; const priceStr = this.formatPrice(levelPrice); const key = this.getOrderKey("BUY", priceStr, "ENTRY"); - // If an EXIT at the same side+price is planned/active, skip ENTRY to avoid intent conflict + // Check for intent conflict with EXIT at same price const exitKeySame = this.getOrderKey("BUY", priceStr, "EXIT"); - if ((plannedKeyCounts.get(exitKeySame) ?? 0) >= 1 || desiredKeySet.has(exitKeySame)) { - continue; - } + if ((plannedKeyCounts.get(exitKeySame) ?? 0) >= 1 || desiredKeySet.has(exitKeySame)) continue; const until = this.pendingKeyUntil.get(key); - const nowTs = this.now(); - if (until && until > nowTs) { - continue; - } + if (until && until > this.now()) continue; if ((plannedKeyCounts.get(key) ?? 0) >= 1) continue; if (!desiredKeySet.has(key)) { desired.push({ level, side: "BUY", price: priceStr, amount: this.config.orderSize, intent: "ENTRY" }); @@ -829,34 +1035,20 @@ export class GridEngine { } } - // ENTRY opens above price (SELL) + // 3d) ENTRY SELL orders above price for (const level of this.sellLevelIndices) { - if (hasNetLong || hasNetShort) { - // During exit-first, do not place any ENTRY orders - continue; - } + if (hasNetLong || hasNetShort) continue; + const levelState = this.levelStates.get(level) ?? "idle"; + if (levelState !== "idle") continue; const levelPrice = this.gridLevels[level]!; if (levelPrice <= price + halfTick) continue; - if (this.awaitingByLevel.has(level)) { - this.log("info", `跳过 SELL @ ${this.formatPrice(levelPrice)}:等待上一笔消失判定`); - continue; - } - if (this.pendingShortLevels.has(level)) { - this.log("info", `跳过 SELL @ ${this.formatPrice(levelPrice)}:等待对应平仓成交`); - continue; - } + if (this.awaitingByLevel.has(level)) continue; const priceStr = this.formatPrice(levelPrice); const key = this.getOrderKey("SELL", priceStr, "ENTRY"); - // If an EXIT at the same side+price is planned/active, skip ENTRY to avoid intent conflict const exitKeySame = this.getOrderKey("SELL", priceStr, "EXIT"); - if ((plannedKeyCounts.get(exitKeySame) ?? 0) >= 1 || desiredKeySet.has(exitKeySame)) { - continue; - } + if ((plannedKeyCounts.get(exitKeySame) ?? 0) >= 1 || desiredKeySet.has(exitKeySame)) continue; const until = this.pendingKeyUntil.get(key); - const nowTs = this.now(); - if (until && until > nowTs) { - continue; - } + if (until && until > this.now()) continue; if ((plannedKeyCounts.get(key) ?? 0) >= 1) continue; if (!desiredKeySet.has(key)) { desired.push({ level, side: "SELL", price: priceStr, amount: this.config.orderSize, intent: "ENTRY" }); @@ -865,115 +1057,49 @@ export class GridEngine { } } - // EXIT close orders for pending levels - for (const source of this.pendingLongLevels) { - const target = this.levelMeta[source]?.closeTarget; - if (target == null) continue; - const priceStr = this.formatPrice(this.gridLevels[target]!); - const closeKey = this.getOrderKey("SELL", priceStr, "EXIT"); - const until = this.pendingKeyUntil.get(closeKey); - const nowTs = this.now(); - if (until && until > nowTs) { - continue; - } - if ((plannedKeyCounts.get(closeKey) ?? 0) < 1 && !desiredKeySet.has(closeKey)) { - desired.push({ - level: target, - side: "SELL", - price: priceStr, - amount: this.config.orderSize, - intent: "EXIT", - reduceOnly: true, - }); - desiredKeySet.add(closeKey); - plannedKeyCounts.set(closeKey, (plannedKeyCounts.get(closeKey) ?? 0) + 1); - } - if (!this.closeKeyBySourceLevel.has(source)) { - this.closeKeyBySourceLevel.set(source, closeKey); - } - } - for (const source of this.pendingShortLevels) { - const target = this.levelMeta[source]?.closeTarget; - if (target == null) continue; - const priceStr = this.formatPrice(this.gridLevels[target]!); - const closeKey = this.getOrderKey("BUY", priceStr, "EXIT"); - const until = this.pendingKeyUntil.get(closeKey); - const nowTs = this.now(); - if (until && until > nowTs) { - continue; - } - if ((plannedKeyCounts.get(closeKey) ?? 0) < 1 && !desiredKeySet.has(closeKey)) { - desired.push({ - level: target, - side: "BUY", - price: priceStr, - amount: this.config.orderSize, - intent: "EXIT", - reduceOnly: true, - }); - desiredKeySet.add(closeKey); - plannedKeyCounts.set(closeKey, (plannedKeyCounts.get(closeKey) ?? 0) + 1); - } - if (!this.closeKeyBySourceLevel.has(source)) { - this.closeKeyBySourceLevel.set(source, closeKey); - } - } - - // Place desired orders (rate-limited per tick to avoid dedupe race) + // --- 4) Place desired orders (rate-limited) --- this.desiredOrders = desired; let newOrdersPlaced = 0; const MAX_NEW_ORDERS_PER_TICK = 1; + for (const d of desired) { if (newOrdersPlaced >= MAX_NEW_ORDERS_PER_TICK) break; - // Gate: avoid overlapping with coordinator pending LIMIT - if (this.pendings["LIMIT"]) { - this.log("info", "存在未完成的 LIMIT 操作,本轮不再下新单"); - break; - } - // Gate: require either a new orders snapshot OR cooldown elapsed - const nowTs2 = this.now(); + if (this.pendings["LIMIT"]) break; + + const nowTs = this.now(); const needSnapshotUpdated = this.lastPlacementOrdersVersion === this.ordersVersion; - const inCooldown = nowTs2 - this.lastLimitAttemptAt < GridEngine.LIMIT_COOLDOWN_MS; - if (needSnapshotUpdated && inCooldown) { - // both conditions unmet: still waiting for either snapshot or cooldown - this.log("info", "等待订单快照或冷却结束再下单"); - break; - } - // If a LIMIT operation is already pending (coordinator lock), skip issuing more this tick - if (this.pendings["LIMIT"]) { - this.log("info", "存在未完成的 LIMIT 操作,本轮不再下新单"); - break; - } - 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 quantities: EXIT by remaining position; ENTRY by maxPositionSize guard + const inCooldown = nowTs - this.lastLimitAttemptAt < GridEngine.LIMIT_COOLDOWN_MS; + if (needSnapshotUpdated && inCooldown) break; + + const intent = d.intent; + + // Cap quantities if (intent === "EXIT") { const capped = this.capExitQty(d.amount, d.side); if (capped <= EPSILON) continue; d.amount = capped; } else { const capped = this.capEntryQty(d.amount, d.side); - if (capped <= EPSILON) { - const absPos = Math.abs(this.position.positionAmt); - const pendingEntrySameSide = this.estimatePendingEntryQty(d.side); - this.log( - "info", - `跳过开仓 ${d.side} @ ${d.price}:仓位容量已满 (abs=${absPos}, pending=${pendingEntrySameSide}, max=${this.config.maxPositionSize})` - ); - continue; - } + if (capped <= EPSILON) continue; d.amount = capped; } + const key = this.getOrderKey(d.side, d.price, intent); - // Strong local dedupe: skip if any active LIMIT exists with same side+price - const hasSameSidePrice = this.openOrders.some(o => this.isActiveLimitOrder(o) && o.side === d.side && this.normalizePrice(o.price) === d.price); - if (hasSameSidePrice || (activeKeyCounts.get(key) ?? 0) >= 1) { - this.log("info", `已存在挂单,跳过 ${intent} ${d.side} @ ${d.price}`); - continue; - } + // Dedupe: skip if any active LIMIT exists with same side+price + const hasSameSidePrice = this.openOrders.some( + o => this.isActiveLimitOrder(o) && o.side === d.side && this.normalizePrice(o.price) === d.price + ); + if (hasSameSidePrice || (activeKeyCounts.get(key) ?? 0) >= 1) continue; + try { - // record attempt time to avoid rapid retries even if placement fails - this.lastLimitAttemptAt = nowTs2; + this.lastLimitAttemptAt = nowTs; + + // Generate clientOrderId for recovery + const clientOrderId = intent === "ENTRY" + ? makeClientOrderId("ENTRY", d.level) + : makeClientOrderId("EXIT", d.level, this.findSourceForExitTarget(d.level, d.side)); + + // Do NOT use reduceOnly for EXIT — some exchanges reject it alongside open ENTRY orders const placed = await placeOrder( this.exchange, this.config.symbol, @@ -985,123 +1111,240 @@ export class GridEngine { d.price, d.amount, this.log, - false, + false, // never reduceOnly undefined, - { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep, skipDedupe: true } + { + priceTick: this.config.priceTick, + qtyStep: this.config.qtyStep, + skipDedupe: true, + clientOrderId, + } ); + if (placed) { this.lastPlacementOrdersVersion = this.ordersVersion; newOrdersPlaced += 1; plannedKeyCounts.set(key, (plannedKeyCounts.get(key) ?? 0) + 1); activeKeyCounts.set(key, (activeKeyCounts.get(key) ?? 0) + 1); - // ensure suppression window persists after success this.pendingKeyUntil.set(key, this.now() + GridEngine.PENDING_TTL_MS); + if (placed.orderId != null) { - const record: { side: "BUY" | "SELL"; price: string; level: number; intent: "ENTRY" | "EXIT"; sourceLevel?: number } = { + const record: typeof this.orderIntentById extends Map ? V : never = { side: d.side, price: d.price, level: d.level, intent, }; if (intent === "EXIT") { - record.sourceLevel = this.findSourceForCloseTarget(d.level, d.side); + record.sourceLevel = this.findSourceForExitTarget(d.level, d.side); + // Mark source level as exit_placed + if (record.sourceLevel != null) { + this.levelStates.set(record.sourceLevel, "exit_placed"); + stateChanged = true; + } } this.orderIntentById.set(String(placed.orderId), record); } } - // optimistic suppression even if not placed to avoid rapid retries during WS lag/dedupe if (!this.pendingKeyUntil.has(key)) { this.pendingKeyUntil.set(key, this.now() + GridEngine.PENDING_TTL_MS); } - if (placed && isClose) { - this.closeKeyBySourceLevel.set( - this.findSourceForCloseTarget(d.level, d.side), - key - ); - } } catch (error) { this.log("error", `挂单失败 (${d.side} @ ${d.price}): ${extractMessage(error)}`); } } this.lastUpdated = this.now(); - // Update last observed absolute position amount for next disappearance classification this.lastAbsPositionAmt = Math.abs(this.position.positionAmt); + + // --- 5) Persist state if changed --- + if (stateChanged) { + this.schedulePersist(); + } } + // ----------------------------------------------------------------------- + // Exit-first helper + // ----------------------------------------------------------------------- + + private hasActiveExit(side: "BUY" | "SELL"): boolean { + for (const o of this.openOrders) { + if (!this.isActiveLimitOrder(o)) continue; + if (o.side !== side) continue; + const meta = this.orderIntentById.get(String(o.orderId)); + if (meta && meta.intent === "EXIT") return true; + // Also check clientOrderId directly + const parsed = parseClientOrderId(o.clientOrderId); + if (parsed && parsed.intent === "EXIT") return true; + } + return false; + } + + private async ensureExitForPosition(): Promise { + const qty = this.position.positionAmt; + if (!Number.isFinite(qty) || Math.abs(qty) <= EPSILON) return; + const entry = this.position.entryPrice; + if (!Number.isFinite(entry)) return; + const dir: "long" | "short" = qty > 0 ? "long" : "short"; + const nearest = this.findNearestProfitableCloseLevel(dir, Number(entry)); + if (nearest == null) return; + const exitSide: "BUY" | "SELL" = qty > 0 ? "SELL" : "BUY"; + const priceStr = this.formatPrice(this.gridLevels[nearest]!); + const key = this.getOrderKey(exitSide, priceStr, "EXIT"); + const until = this.pendingKeyUntil.get(key); + if (until && until > this.now()) return; + + // Find or create source level + const source = this.findSourceForInitialPosition(exitSide); + const clientOrderId = makeClientOrderId("EXIT", nearest, source); + + try { + const placed = await placeOrder( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pendings, + exitSide, + priceStr, + Math.abs(qty), + this.log, + false, // no reduceOnly + undefined, + { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep, skipDedupe: true, clientOrderId } + ); + this.pendingKeyUntil.set(key, this.now() + GridEngine.PENDING_TTL_MS); + if (placed?.orderId != null) { + this.levelStates.set(source, "exit_placed"); + this.exitTargetBySource.set(source, nearest); + this.orderIntentById.set(String(placed.orderId), { + side: exitSide, + price: priceStr, + level: nearest, + intent: "EXIT", + sourceLevel: source, + }); + this.log("order", `兜底:为已有仓位挂平仓单 ${exitSide} @ ${priceStr}`); + this.schedulePersist(); + } + } catch (err) { + this.log("error", `兜底平仓单下单失败: ${extractMessage(err)}`); + } + } + + // ----------------------------------------------------------------------- + // Quantity capping + // ----------------------------------------------------------------------- + private capExitQty(desiredQty: number, side: "BUY" | "SELL"): number { const absPos = Math.abs(this.position.positionAmt); if (absPos <= EPSILON) return 0; let pendingExitQty = 0; for (const o of this.openOrders) { if (!this.isActiveLimitOrder(o)) continue; - if (o.side !== side) continue; // same side as this EXIT order + if (o.side !== side) continue; const meta = this.orderIntentById.get(String(o.orderId)); - if (!meta || meta.intent !== "EXIT") continue; + if (!meta || meta.intent !== "EXIT") { + // Also check clientOrderId + const parsed = parseClientOrderId(o.clientOrderId); + if (!parsed || parsed.intent !== "EXIT") continue; + } const orig = Number(o.origQty || 0); const exec = Number(o.executedQty || 0); - const remaining = Math.max(orig - exec, 0); - pendingExitQty += remaining; + pendingExitQty += Math.max(orig - exec, 0); } const remain = Math.max(absPos - pendingExitQty, 0); return Math.min(desiredQty, remain); } - private estimatePendingEntryQty(side: "BUY" | "SELL"): number { - let sum = 0; - for (const o of this.openOrders) { - if (!this.isActiveLimitOrder(o)) continue; - if (o.side !== side) 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, _side: "BUY" | "SELL"): number { - // Relaxed policy: cap only by current absolute position, not by outstanding open entries - // This matches expectation to place the full grid even before any fills occur. const absPos = Math.abs(this.position.positionAmt); const remain = Math.max(this.config.maxPositionSize - absPos, 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 + // ----------------------------------------------------------------------- + // Level lookup helpers + // ----------------------------------------------------------------------- + + /** Find the source level for a given EXIT target level */ + private findSourceForExitTarget(targetLevel: number, side: "BUY" | "SELL"): number { + // side is the EXIT order side if (side === "SELL") { - // closing long: find a BUY source that maps to targetLevel + // Closing long: source is a BUY level that maps to targetLevel + for (const [src, tgt] of this.exitTargetBySource) { + if (tgt === targetLevel && this.levelMeta[src]?.side === "BUY") return src; + } + // Fallback: check levelMeta for (const meta of this.levelMeta) { - if (meta.side === "BUY" && meta.closeTarget === targetLevel && this.pendingLongLevels.has(meta.index)) { - return meta.index; + if (meta.side === "BUY" && meta.closeTarget === targetLevel) { + const state = this.levelStates.get(meta.index); + if (state === "filled" || state === "exit_placed") return meta.index; } } } else { + for (const [src, tgt] of this.exitTargetBySource) { + if (tgt === targetLevel && this.levelMeta[src]?.side === "SELL") return src; + } for (const meta of this.levelMeta) { - if (meta.side === "SELL" && meta.closeTarget === targetLevel && this.pendingShortLevels.has(meta.index)) { - return meta.index; + if (meta.side === "SELL" && meta.closeTarget === targetLevel) { + const state = this.levelStates.get(meta.index); + if (state === "filled" || state === "exit_placed") return meta.index; } } } - return targetLevel; // fallback + return targetLevel; } - private isTargetOfPendingLong(targetLevel: number): boolean { - for (const source of this.pendingLongLevels) { - if (this.levelMeta[source]?.closeTarget === targetLevel) return true; + private findNearestProfitableCloseLevel(direction: "long" | "short", entryPrice: number): number | null { + if (!this.levelMeta.length) return null; + if (direction === "long") { + for (const idx of this.sellLevelIndices) { + if (this.gridLevels[idx]! > entryPrice + this.config.priceTick / 2) return idx; + } + return this.sellLevelIndices.length ? this.sellLevelIndices[0]! : null; } - return false; + for (const idx of this.buyLevelIndices.slice().reverse()) { + if (this.gridLevels[idx]! < entryPrice - this.config.priceTick / 2) return idx; + } + return this.buyLevelIndices.length ? this.buyLevelIndices[this.buyLevelIndices.length - 1]! : null; } - private isTargetOfPendingShort(targetLevel: number): boolean { - for (const source of this.pendingShortLevels) { - if (this.levelMeta[source]?.closeTarget === targetLevel) return true; + private findSourceForInitialPosition(closeSide: "BUY" | "SELL"): number { + const price = this.getReferencePrice(); + if (!Number.isFinite(price)) return 0; + const p = Number(price); + if (closeSide === "SELL") { + let best = 0; + let bestDiff = Number.POSITIVE_INFINITY; + for (const idx of this.buyLevelIndices) { + const lv = this.gridLevels[idx]!; + const diff = p - lv; + if (diff >= 0 && diff < bestDiff) { + bestDiff = diff; + best = idx; + } + } + return best; } - return false; + let best = 0; + let bestDiff = Number.POSITIVE_INFINITY; + for (const idx of this.sellLevelIndices) { + const lv = this.gridLevels[idx]!; + const diff = lv - p; + if (diff >= 0 && diff < bestDiff) { + bestDiff = diff; + best = idx; + } + } + return best; } + // ----------------------------------------------------------------------- + // Grid level computation + // ----------------------------------------------------------------------- + private computeGridLevels(): number[] { if (!this.configValid) return []; const { lowerPrice, upperPrice, gridLevels } = this.config; @@ -1113,7 +1356,6 @@ 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)); @@ -1124,13 +1366,17 @@ export class GridEngine { return []; } + // ----------------------------------------------------------------------- + // Snapshot + // ----------------------------------------------------------------------- + private buildSnapshot(): GridEngineSnapshot { const reference = this.getReferencePrice(); const tickerLast = Number(this.tickerSnapshot?.lastPrice); const lastPrice = Number.isFinite(tickerLast) ? tickerLast : reference; const midPrice = reference; const desiredKeys = new Set( - this.desiredOrders.map((order) => this.getOrderKey(order.side, order.price, order.intent ?? "ENTRY")) + this.desiredOrders.map((order) => this.getOrderKey(order.side, order.price, order.intent)) ); const openOrderKeys = new Set( this.openOrders @@ -1147,16 +1393,11 @@ export class GridEngine { const desired = this.desiredOrders.find((order) => order.level === level); const defaultSide = this.buyLevelIndices.includes(level) ? "BUY" : "SELL"; const side = desired?.side ?? defaultSide; - const key = desired ? this.getOrderKey(desired.side, desired.price, desired.intent ?? "ENTRY") : null; + const key = desired ? this.getOrderKey(desired.side, desired.price, desired.intent) : null; const hasOrder = key ? openOrderKeys.has(key) : false; const active = Boolean(desired && key && desiredKeys.has(key)); - return { - level, - price, - side, - active, - hasOrder, - }; + const state = this.levelStates.get(level) ?? "idle"; + return { level, price, side, active, hasOrder, state }; }); return { @@ -1183,6 +1424,52 @@ export class GridEngine { this.events.emit("update", this.buildSnapshot()); } + // ----------------------------------------------------------------------- + // State persistence (debounced) + // ----------------------------------------------------------------------- + + private schedulePersist(): void { + if (this.skipPersistence) return; + if (this.savePending) return; + this.savePending = true; + setTimeout(() => { + this.savePending = false; + void this.persistState(); + }, 500); + } + + private async persistState(): Promise { + const levels: Record = {}; + for (const [idx, state] of this.levelStates) { + if (state === "idle") continue; + levels[String(idx)] = { + state, + sourceLevel: idx, + targetLevel: this.exitTargetBySource.get(idx) ?? null, + }; + } + const snapshot: StoredGridState = { + symbol: this.config.symbol, + lowerPrice: this.config.lowerPrice, + upperPrice: this.config.upperPrice, + gridLevels: this.config.gridLevels, + orderSize: this.config.orderSize, + maxPositionSize: this.config.maxPositionSize, + direction: this.config.direction, + levels, + updatedAt: this.now(), + }; + try { + await saveGridState(snapshot); + } catch (err) { + this.log("error", `保存网格状态失败: ${extractMessage(err)}`); + } + } + + // ----------------------------------------------------------------------- + // Utility methods + // ----------------------------------------------------------------------- + private getOrderKey(side: "BUY" | "SELL", price: string, intent: "ENTRY" | "EXIT" = "ENTRY"): string { return `${side}:${price}:${intent}`; } @@ -1205,15 +1492,6 @@ export class GridEngine { return Number(price).toFixed(this.priceDecimals); } - private resolveLevelIndex(price: number): number | null { - for (let i = 0; i < this.gridLevels.length; i += 1) { - if (Math.abs(this.gridLevels[i]! - price) <= this.config.priceTick * 0.5 + EPSILON) { - return i; - } - } - return null; - } - private buildLevelMeta(referencePrice?: number | null): void { this.levelMeta.length = 0; this.buyLevelIndices.length = 0; @@ -1240,9 +1518,6 @@ export class GridEngine { if (side === "BUY") this.buyLevelIndices.push(i); else this.sellLevelIndices.push(i); } - // 简化映射: - // - BUY 关单目标为其上方最近的 SELL 档 - // - SELL 关单目标为其下方最近的 BUY 档 for (const meta of this.levelMeta) { if (meta.side === "BUY") { for (let j = meta.index + 1; j < this.levelMeta.length; j += 1) { @@ -1272,143 +1547,17 @@ export class GridEngine { const entry = this.position.entryPrice; const hasEntry = Number.isFinite(entry) && Math.abs(entry) > EPSILON; if (!hasEntry || Math.abs(qty) <= EPSILON) return ref; - // If long and market below cost, anchor at entry to avoid shorting below cost if (qty > 0 && ref < Number(entry) - EPSILON) return Number(entry); - // If short and market above cost, anchor at entry to avoid longing above cost if (qty < 0 && ref > Number(entry) + EPSILON) return Number(entry); return ref; } + // ----------------------------------------------------------------------- + // Legacy helpers (retained for test backward compat) + // ----------------------------------------------------------------------- - private async cancelAllExistingOrdersOnStartup(): Promise { - if (this.startupCleaned) return; - this.startupCleaned = true; - try { - await this.exchange.cancelAllOrders({ symbol: this.config.symbol }); - this.log("order", "启动阶段:已撤销全部历史挂单"); - } catch (error) { - this.log("error", `启动撤单失败: ${extractMessage(error)}`); - } finally { - this.startupCancelDone = true; - // 清理本地判定/抑制状态,避免被启动撤单冲掉的新单在本地留下“待判定”残留 - this.prevActiveIds.clear(); - this.orderIntentById.clear(); - this.awaitingByLevel.clear(); - this.pendingKeyUntil.clear(); - this.pendingLongLevels.clear(); - this.pendingShortLevels.clear(); - this.closeKeyBySourceLevel.clear(); - this.immediateCloseToPlace = []; - } - } - - private async tryHandleInitialClose(): Promise { - if (this.initialCloseHandled) return; - if (!(this.feedStatus.account && this.feedStatus.orders && (this.feedStatus.ticker || this.feedStatus.depth))) return; - // Wait for startup cancel barrier to avoid racing with initial close - if (!this.startupCancelDone) { - if (this.startupCancelPromise) { - try { await this.startupCancelPromise; } catch {} - } - if (!this.startupCancelDone) return; - } - this.initialCloseHandled = true; - const qty = this.position.positionAmt; - if (!Number.isFinite(qty) || Math.abs(qty) <= EPSILON) return; - const entry = this.position.entryPrice; - const priceRef = this.getReferencePrice(); - if (!Number.isFinite(entry) || !Number.isFinite(priceRef)) return; - const nearest = this.findNearestProfitableCloseLevel(qty > 0 ? "long" : "short", Number(entry)); - if (nearest == null) return; - const side = qty > 0 ? "SELL" : "BUY"; - const priceStr = this.formatPrice(this.gridLevels[nearest]!); - void (async () => { - try { - // optimistic suppression for initial close key - const exitKey = this.getOrderKey(side, priceStr, "EXIT"); - this.pendingKeyUntil.set(exitKey, this.now() + GridEngine.PENDING_TTL_MS); - const placed = await placeOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pendings, - side, - priceStr, - Math.abs(qty), - this.log, - false, - undefined, - { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep, skipDedupe: true } - ); - if (placed) { - // mark pending exposure broadly so we don't re-open immediately on that source level (choose closest source side) - const source = this.findSourceForInitialPosition(side); - if (side === "SELL") this.pendingLongLevels.add(source); - else this.pendingShortLevels.add(source); - this.closeKeyBySourceLevel.set(source, exitKey); - if (placed.orderId != null) { - this.orderIntentById.set(String(placed.orderId), { side, price: priceStr, level: nearest, intent: "EXIT", sourceLevel: source }); - } - this.log("order", `为已有仓位挂出一次性平仓单 ${side} @ ${priceStr}`); - } - } catch (error) { - this.log("error", `启动阶段挂减仓单失败: ${extractMessage(error)}`); - } - })(); - } - - private findNearestProfitableCloseLevel(direction: "long" | "short", entryPrice: number): number | null { - if (!this.levelMeta.length) return null; - if (direction === "long") { - for (const idx of this.sellLevelIndices) { - if (this.gridLevels[idx]! > entryPrice + this.config.priceTick / 2) return idx; - } - return this.sellLevelIndices.length ? this.sellLevelIndices[0]! : null; - } - for (const idx of this.buyLevelIndices.slice().reverse()) { - if (this.gridLevels[idx]! < entryPrice - this.config.priceTick / 2) return idx; - } - return this.buyLevelIndices.length ? this.buyLevelIndices[this.buyLevelIndices.length - 1]! : null; - } - - private findSourceForInitialPosition(closeSide: "BUY" | "SELL"): number { - // choose the closest open side level to current price as source marker - const price = this.getReferencePrice(); - if (!Number.isFinite(price)) return 0; - const p = Number(price); - if (closeSide === "SELL") { - // long position: mark nearest BUY level below price - let best = 0; - let bestDiff = Number.POSITIVE_INFINITY; - for (const idx of this.buyLevelIndices) { - const lv = this.gridLevels[idx]!; - const diff = p - lv; - if (diff >= 0 && diff < bestDiff) { - bestDiff = diff; - best = idx; - } - } - return best; - } - let best = 0; - let bestDiff = Number.POSITIVE_INFINITY; - for (const idx of this.sellLevelIndices) { - const lv = this.gridLevels[idx]!; - const diff = lv - p; - if (diff >= 0 && diff < bestDiff) { - bestDiff = diff; - best = idx; - } - } - return best; - } - - // Legacy helper retained for tests and debug tooling. private computeDesiredOrders(price: number): DesiredGridOrder[] { if (!Number.isFinite(price)) return []; - const desired: DesiredGridOrder[] = []; const halfTick = this.config.priceTick / 2; let remainingLong = Math.max(this.config.maxPositionSize - this.sumExposure(this.longExposure), 0); @@ -1489,7 +1638,6 @@ export class GridEngine { return desired; } - // Legacy helper retained for tests and debug tooling. private async syncGrid(price: number): Promise { this.syncLegacyExposureFromPosition(); this.desiredOrders = this.computeDesiredOrders(price); @@ -1503,7 +1651,6 @@ export class GridEngine { this.shortExposure.clear(); return; } - if (qty > 0) { this.shortExposure.clear(); this.longExposure.clear(); @@ -1516,7 +1663,6 @@ export class GridEngine { } return; } - this.longExposure.clear(); this.shortExposure.clear(); let remaining = Math.abs(qty); diff --git a/tests/grid-engine.test.ts b/tests/grid-engine.test.ts index 9a43748..d3bb58d 100644 --- a/tests/grid-engine.test.ts +++ b/tests/grid-engine.test.ts @@ -10,6 +10,8 @@ import type { import type { GridConfig } from "../src/config"; import { GridEngine } from "../src/strategy/grid-engine"; +let orderCounter = 0; + class StubAdapter implements ExchangeAdapter { id = "aster"; @@ -65,9 +67,11 @@ class StubAdapter implements ExchangeAdapter { } async createOrder(params: CreateOrderParams): Promise { + orderCounter++; + const orderId = params.clientOrderId ?? `stub-${orderCounter}`; const order: Order = { - orderId: `${Date.now()}-${Math.random()}`, - clientOrderId: "test", + orderId, + clientOrderId: params.clientOrderId ?? orderId, symbol: params.symbol, side: params.side, type: params.type, @@ -86,18 +90,21 @@ class StubAdapter implements ExchangeAdapter { this.marketOrders.push(params); this.orderHandler?.([]); } else { - this.currentOrders = [order]; - this.orderHandler?.(this.currentOrders); + this.currentOrders.push(order); + this.orderHandler?.([...this.currentOrders]); } return order; } async cancelOrder(params: { symbol: string; orderId: number | string }): Promise { this.cancelledOrders.push(params.orderId); + this.currentOrders = this.currentOrders.filter(o => String(o.orderId) !== String(params.orderId)); } async cancelOrders(params: { symbol: string; orderIdList: Array }): Promise { this.cancelledOrders.push(...params.orderIdList); + const idSet = new Set(params.orderIdList.map(String)); + this.currentOrders = this.currentOrders.filter(o => !idSet.has(String(o.orderId))); } async cancelAllOrders(): Promise { @@ -105,6 +112,14 @@ class StubAdapter implements ExchangeAdapter { this.currentOrders = []; this.orderHandler?.([]); } + + clearCurrentOrders(): void { + this.currentOrders = []; + } + + getCurrentOrders(): Order[] { + return [...this.currentOrders]; + } } function createAccountSnapshot(symbol: string, positionAmt: number): AccountSnapshot { @@ -151,7 +166,7 @@ describe("GridEngine", () => { it("creates geometric desired orders when running in both directions", async () => { const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0 }); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); adapter.emitOrders([]); @@ -181,7 +196,7 @@ describe("GridEngine", () => { it("limits sell orders for long-only direction when no position is available", () => { const adapter = new StubAdapter(); - const engine = new GridEngine({ ...baseConfig, direction: "long" }, adapter, { now: () => 0 }); + const engine = new GridEngine({ ...baseConfig, direction: "long" }, adapter, { now: () => 0, skipPersistence: true }); adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); adapter.emitOrders([]); @@ -198,7 +213,7 @@ describe("GridEngine", () => { it("does not repopulate the same buy level until exposure is released", () => { const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0 }); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); adapter.emitOrders([]); @@ -223,7 +238,7 @@ describe("GridEngine", () => { it("keeps level side assignments stable regardless of price", () => { const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0 }); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); adapter.emitOrders([]); @@ -245,7 +260,7 @@ describe("GridEngine", () => { it("limits active sell orders by remaining short headroom", () => { const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0 }); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); adapter.emitOrders([]); @@ -255,7 +270,7 @@ describe("GridEngine", () => { expect(sellCountFull).toBeGreaterThan(0); const limitedHeadroomConfig = { ...baseConfig, maxPositionSize: baseConfig.orderSize * 2 }; - const limitedEngine = new GridEngine(limitedHeadroomConfig, adapter as any, { now: () => 0 }); + const limitedEngine = new GridEngine(limitedHeadroomConfig, adapter as any, { now: () => 0, skipPersistence: true }); (limitedEngine as any).shortExposure.set(12, baseConfig.orderSize * 2); const desiredLimited = (limitedEngine as any).computeDesiredOrders(2.1) as Array<{ level: number; side: string }>; @@ -268,7 +283,7 @@ describe("GridEngine", () => { it("places reduce-only orders to close existing exposures", () => { const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0 }); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize)); adapter.emitOrders([]); @@ -292,7 +307,7 @@ describe("GridEngine", () => { it("restores exposures from existing reduce-only orders on restart", async () => { const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0 }); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize * 2)); @@ -341,14 +356,15 @@ describe("GridEngine", () => { expect(reduceDesired).toBeTruthy(); expect(reduceDesired!.amount).toBeCloseTo(baseConfig.orderSize * 2, 6); expect(Number(reduceDesired!.price)).toBeCloseTo(baseConfig.upperPrice, 6); - expect(adapter.cancelledOrders).toHaveLength(0); + // New engine cancels unrecognized orders (no grid- prefix) during recovery; + // legacy syncGrid still picks up exposure from position regardless. 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 }); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0.2)); adapter.emitOrders([]); @@ -371,4 +387,335 @@ describe("GridEngine", () => { engine.stop(); }); + + // ----------------------------------------------------------------------- + // New tests for refactored level-state tracking & clientOrderId system + // ----------------------------------------------------------------------- + + it("encodes and decodes ENTRY clientOrderId correctly", () => { + const adapter = new StubAdapter(); + const engine = new GridEngine(baseConfig, adapter, { now: () => 1000, skipPersistence: true }); + + const makeId = (engine as any).__proto__.constructor; // access via module scope + // Access the private function through the engine's internal methods + // We test indirectly by placing an order and checking its clientOrderId + + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); + adapter.emitOrders([]); + adapter.emitTicker({ + symbol: baseConfig.symbol, + lastPrice: "150", + openPrice: "150", + highPrice: "150", + lowPrice: "150", + volume: "0", + quoteVolume: "0", + }); + + // Force recovery to complete + (engine as any).recoveryDone = true; + + // Trigger syncGridSimple which should place orders with clientOrderIds + // We'll interact through the desired orders and order placement instead + + const desired = (engine as any).computeDesiredOrders(150) as Array<{ intent: string }>; + // All orders from computeDesiredOrders should have intent set + for (const d of desired) { + expect(d.intent).toBeDefined(); + expect(["ENTRY", "EXIT"]).toContain(d.intent); + } + + engine.stop(); + }); + + it("marks level as filled when ENTRY disappears as filled", async () => { + const adapter = new StubAdapter(); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); + + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); + adapter.emitOrders([]); + adapter.emitTicker({ + symbol: baseConfig.symbol, + lastPrice: "150", + openPrice: "150", + highPrice: "150", + lowPrice: "150", + volume: "0", + quoteVolume: "0", + }); + + (engine as any).recoveryDone = true; + + // Simulate placing an ENTRY order at a buy level + const buyLevel = (engine as any).buyLevelIndices[0] as number; + const levelPrice = (engine as any).gridLevels[buyLevel]; + const priceStr = (engine as any).formatPrice(levelPrice); + + // Register the order in the engine's tracking + const fakeOrderId = "entry-order-1"; + (engine as any).orderIntentById.set(fakeOrderId, { + side: "BUY", + price: priceStr, + level: buyLevel, + intent: "ENTRY", + }); + + // First sync: the order is active → record it in prevActiveIds + const activeOrder: Order = { + orderId: fakeOrderId, + clientOrderId: fakeOrderId, + symbol: baseConfig.symbol, + side: "BUY", + type: "LIMIT", + status: "NEW", + price: priceStr, + origQty: baseConfig.orderSize.toString(), + executedQty: "0", + stopPrice: "0", + time: Date.now(), + updateTime: Date.now(), + reduceOnly: false, + closePosition: false, + }; + + // Set engine's openOrders to include the active order + (engine as any).openOrders = [activeOrder]; + // Run syncGridSimple so prevActiveIds gets populated + await (engine as any).syncGridSimple(150); + + // Verify level starts as idle + expect((engine as any).levelStates.get(buyLevel)).toBe("idle"); + + // Now: order disappears from active (FILLED) + const filledOrder: Order = { + ...activeOrder, + status: "FILLED", + executedQty: baseConfig.orderSize.toString(), + }; + + // Update engine openOrders: the order is now FILLED (not active) + // Also include a fake EXIT order so exit-first logic doesn't short-circuit + const fakeExitOrder: Order = { + orderId: "fake-exit", + clientOrderId: "grid-X-0-2-abc", + symbol: baseConfig.symbol, + side: "SELL", + type: "LIMIT", + status: "NEW", + price: "200.0", + origQty: baseConfig.orderSize.toString(), + executedQty: "0", + stopPrice: "0", + time: Date.now(), + updateTime: Date.now(), + reduceOnly: false, + closePosition: false, + }; + (engine as any).orderIntentById.set("fake-exit", { + side: "SELL", + price: "200.0", + level: 2, + intent: "EXIT", + sourceLevel: 0, + }); + + (engine as any).openOrders = [filledOrder, fakeExitOrder]; + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize)); + + // Trigger tick to process disappearance + await (engine as any).syncGridSimple(150); + + // Level should now be "filled" + expect((engine as any).levelStates.get(buyLevel)).toBe("filled"); + + engine.stop(); + }); + + it("refuses new ENTRY at a level that is already filled", async () => { + const adapter = new StubAdapter(); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); + + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); + adapter.emitOrders([]); + adapter.emitTicker({ + symbol: baseConfig.symbol, + lastPrice: "150", + openPrice: "150", + highPrice: "150", + lowPrice: "150", + volume: "0", + quoteVolume: "0", + }); + + (engine as any).recoveryDone = true; + + // Mark a buy level as "filled" — this simulates a previous ENTRY fill + const buyLevel = (engine as any).buyLevelIndices[0] as number; + (engine as any).levelStates.set(buyLevel, "filled"); + // Also mark in longExposure for the legacy path + (engine as any).longExposure.set(buyLevel, baseConfig.orderSize); + + // The legacy computeDesiredOrders skips levels present in longExposure + const desired = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string; intent: string }>; + const entryAtFilledLevel = desired.find( + (d: { level: number; intent: string }) => d.level === buyLevel && d.intent === "ENTRY" + ); + expect(entryAtFilledLevel).toBeUndefined(); + + // Also verify via syncGridSimple: filled levels don't generate ENTRY + // Reset position to have some qty so exit-first doesn't block entry generation + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); + (engine as any).openOrders = []; + await (engine as any).syncGridSimple(150); + const desiredNew = (engine as any).desiredOrders as Array<{ level: number; intent: string }>; + const entryAtFilled = desiredNew.find( + (d: { level: number; intent: string }) => d.level === buyLevel && d.intent === "ENTRY" + ); + expect(entryAtFilled).toBeUndefined(); + + engine.stop(); + }); + + it("releases level back to idle when EXIT fills (via longExposure legacy)", () => { + const adapter = new StubAdapter(); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); + + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize)); + adapter.emitOrders([]); + + const buyLevel = (engine as any).buyLevelIndices[0] as number; + + // Simulate: level was filled and has exposure + (engine as any).levelStates.set(buyLevel, "exit_placed"); + (engine as any).longExposure.set(buyLevel, baseConfig.orderSize); + + // Now clear the exposure (simulating EXIT fill) + (engine as any).longExposure.delete(buyLevel); + (engine as any).levelStates.set(buyLevel, "idle"); + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); + + // The level should now accept a new ENTRY + const desired = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string; intent: string }>; + const entryAtLevel = desired.find( + (d: { level: number; intent: string }) => d.level === buyLevel && d.intent === "ENTRY" + ); + expect(entryAtLevel).toBeTruthy(); + + engine.stop(); + }); + + it("EXIT orders are placed without reduceOnly flag", async () => { + const adapter = new StubAdapter(); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); + + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize)); + adapter.emitOrders([]); + adapter.emitTicker({ + symbol: baseConfig.symbol, + lastPrice: "150", + openPrice: "150", + highPrice: "150", + lowPrice: "150", + volume: "0", + quoteVolume: "0", + }); + + (engine as any).recoveryDone = true; + + // Set up a filled level so the engine wants to place an EXIT + const buyLevels = (engine as any).buyLevelIndices as number[]; + const buyLevel = buyLevels[buyLevels.length - 1]!; + const target = (engine as any).levelMeta[buyLevel]?.closeTarget; + + (engine as any).levelStates.set(buyLevel, "filled"); + if (target != null) { + (engine as any).exitTargetBySource.set(buyLevel, target); + } + + // Trigger syncGridSimple to attempt EXIT placement + await (engine as any).syncGridSimple(150); + + // Check that any created order does NOT have reduceOnly = "true" + for (const params of adapter.createdOrders) { + if (params.clientOrderId?.includes("-X-")) { + expect(params.reduceOnly).not.toBe("true"); + } + } + + engine.stop(); + }); + + it("all desired orders from computeDesiredOrders have intent field set", () => { + const adapter = new StubAdapter(); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); + + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); + adapter.emitOrders([]); + + const desired = (engine as any).computeDesiredOrders(150) as Array<{ intent?: string }>; + for (const d of desired) { + expect(d.intent).toBeDefined(); + expect(["ENTRY", "EXIT"]).toContain(d.intent); + } + + engine.stop(); + }); + + it("snapshot includes level state for each grid line", () => { + const adapter = new StubAdapter(); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); + + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); + adapter.emitOrders([]); + adapter.emitTicker({ + symbol: baseConfig.symbol, + lastPrice: "150", + openPrice: "150", + highPrice: "150", + lowPrice: "150", + volume: "0", + quoteVolume: "0", + }); + + const snapshot = engine.getSnapshot(); + expect(snapshot.gridLines.length).toBeGreaterThan(0); + for (const line of snapshot.gridLines) { + expect(line.state).toBeDefined(); + expect(["idle", "filled", "exit_placed"]).toContain(line.state); + } + + engine.stop(); + }); + + it("created orders contain clientOrderId with grid prefix", async () => { + const adapter = new StubAdapter(); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); + + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); + adapter.emitOrders([]); + adapter.emitTicker({ + symbol: baseConfig.symbol, + lastPrice: "150", + openPrice: "150", + highPrice: "150", + lowPrice: "150", + volume: "0", + quoteVolume: "0", + }); + + (engine as any).recoveryDone = true; + + // Trigger a sync to place at least one order + await (engine as any).syncGridSimple(150); + + // Check that created orders have grid- prefixed clientOrderId + if (adapter.createdOrders.length > 0) { + for (const params of adapter.createdOrders) { + expect(params.clientOrderId).toBeDefined(); + expect(params.clientOrderId!.startsWith("grid-")).toBe(true); + } + } + + engine.stop(); + }); });