From 968b585688ddcf1be1528e2e02b3dedfdfa8ebfd Mon Sep 17 00:00:00 2001 From: discountry Date: Wed, 19 Nov 2025 01:32:20 +0800 Subject: [PATCH] refactor: update grid configuration and order handling in GridEngine to enhance flexibility and performance --- src/config.ts | 62 +- src/exchanges/grvt/gateway.ts | 3 +- src/exchanges/grvt/order.ts | 5 +- src/exchanges/order-schema.ts | 2 +- src/exchanges/types.ts | 1 + src/strategy/common/grid-storage.ts | 79 -- src/strategy/grid-engine.ts | 1593 ++++++++------------------- src/ui/GridApp.tsx | 8 +- 8 files changed, 501 insertions(+), 1252 deletions(-) delete mode 100644 src/strategy/common/grid-storage.ts diff --git a/src/config.ts b/src/config.ts index 08ea03d..281aaa4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -116,25 +116,16 @@ export interface BasisArbConfig { arbAmount: number; // base asset amount to arb (e.g., ASTER amount when ASTERUSDT) } -export type GridDirection = "both" | "long" | "short"; - export interface GridConfig { symbol: string; - lowerPrice: number; - upperPrice: number; - gridLevels: number; - orderSize: number; - maxPositionSize: number; + tradeAmount: number; refreshIntervalMs: number; - maxLogEntries: number; priceTick: number; qtyStep: number; - direction: GridDirection; - stopLossPct: number; - restartTriggerPct: number; - autoRestart: boolean; - gridMode: "geometric"; - maxCloseSlippagePct: number; + levelsPerSide: number; + spacingPct: number; + stopLossBufferPct: number; + maxLogEntries: number; } const resolveBasisSymbol = (envKeys: string[], fallback: string): string => { @@ -162,49 +153,18 @@ export const basisConfig: BasisArbConfig = { arbAmount: parseNumber(process.env.ARB_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0)), }; -const resolveGridDirection = (raw: string | undefined, fallback: GridDirection): GridDirection => { - if (!raw) return fallback; - const normalized = raw.trim().toLowerCase(); - if (normalized === "long" || normalized === "long-only") return "long"; - if (normalized === "short" || normalized === "short-only") return "short"; - if (normalized === "both" || normalized === "dual" || normalized === "bi" || normalized === "two-way") return "both"; - return fallback; -}; - -const resolveGridMaxPosition = (orderSize: number, levels: number): number => { - const fallback = Math.max(orderSize * Math.max(levels - 1, 1), orderSize); - const raw = process.env.GRID_MAX_POSITION_SIZE ?? process.env.GRID_MAX_POSITION ?? process.env.GRID_POSITION_CAP; - const parsed = parseNumber(raw, fallback); - return parsed > 0 ? parsed : fallback; -}; - export const gridConfig: GridConfig = { symbol: resolveSymbolFromEnv(), - lowerPrice: parseNumber(process.env.GRID_LOWER_PRICE ?? process.env.GRID_LOWER_BOUND, 0), - upperPrice: parseNumber(process.env.GRID_UPPER_PRICE ?? process.env.GRID_UPPER_BOUND, 0), - gridLevels: Math.max(2, Math.floor(parseNumber(process.env.GRID_LEVELS, 10))), - orderSize: parseNumber(process.env.GRID_ORDER_SIZE, parseNumber(process.env.TRADE_AMOUNT, 0.001)), - maxPositionSize: 0, // placeholder, replaced below - refreshIntervalMs: parseNumber(process.env.GRID_REFRESH_INTERVAL_MS, 1_000), - maxLogEntries: parseNumber(process.env.GRID_MAX_LOG_ENTRIES, 200), + tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001), + refreshIntervalMs: parseNumber(process.env.GRID_REFRESH_INTERVAL_MS, 800), priceTick: parseNumber(process.env.GRID_PRICE_TICK ?? process.env.PRICE_TICK, 0.1), qtyStep: parseNumber(process.env.GRID_QTY_STEP ?? process.env.QTY_STEP, 0.001), - direction: resolveGridDirection(process.env.GRID_DIRECTION, "both"), - stopLossPct: Math.max(0, parseNumber(process.env.GRID_STOP_LOSS_PCT, 0.01)), - restartTriggerPct: Math.max(0, parseNumber(process.env.GRID_RESTART_TRIGGER_PCT, 0.01)), - autoRestart: parseBoolean(process.env.GRID_AUTO_RESTART_ENABLED ?? process.env.GRID_ENABLE_AUTO_RESTART, true), - gridMode: "geometric", - maxCloseSlippagePct: Math.max( - 0, - parseNumber( - process.env.GRID_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT, - 0.05 - ) - ), + levelsPerSide: Math.max(5, Math.floor(parseNumber(process.env.GRID_LEVELS_PER_SIDE, 24))), + spacingPct: Math.max(0.0002, parseNumber(process.env.GRID_SPACING_PCT, 0.0005)), + stopLossBufferPct: Math.max(0.001, parseNumber(process.env.GRID_STOP_BUFFER_PCT, 0.003)), + maxLogEntries: parseNumber(process.env.GRID_MAX_LOG_ENTRIES, 200), }; -gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.orderSize, gridConfig.gridLevels); - export function isBasisStrategyEnabled(): boolean { const raw = process.env.ENABLE_BASIS_STRATEGY; if (!raw) return false; diff --git a/src/exchanges/grvt/gateway.ts b/src/exchanges/grvt/gateway.ts index afb2a0f..47cfb50 100644 --- a/src/exchanges/grvt/gateway.ts +++ b/src/exchanges/grvt/gateway.ts @@ -1201,6 +1201,7 @@ function toApiOrderPayload(order: GrvtSignedOrder): IOrder { const metadata = order.metadata ? toApiOrderMetadata(order.metadata) : undefined; return { ...order, + client_order_id: order.metadata?.client_order_id, time_in_force: toApiTimeInForce(order.time_in_force), metadata, }; @@ -1300,7 +1301,7 @@ function buildUnsignedOrder(params: { const trigger = buildTriggerMetadata(orderParams); const metadata = { - client_order_id: generateClientOrderId(), + client_order_id: orderParams.clientOrderId ?? generateClientOrderId(), ...(trigger ? { trigger } : {}), }; diff --git a/src/exchanges/grvt/order.ts b/src/exchanges/grvt/order.ts index ac875b3..972a52a 100644 --- a/src/exchanges/grvt/order.ts +++ b/src/exchanges/grvt/order.ts @@ -22,6 +22,9 @@ function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): if (intent.closePosition !== undefined) { params.closePosition = toStringBoolean(intent.closePosition); } + if (intent.clientOrderId) { + params.clientOrderId = intent.clientOrderId; + } return params; } @@ -34,6 +37,7 @@ export async function createLimitOrder(intent: LimitOrderIntent): Promise; - shortExposure: Record; - updatedAt: number; -} - -type GridStateMap = Record; - -async function ensureDataDir(): Promise { - try { - await fs.mkdir(DATA_DIR, { recursive: true }); - } catch { - // ignore - } -} - -async function readStateFile(): Promise { - try { - const content = await fs.readFile(GRID_FILE, "utf8"); - const parsed = JSON.parse(content); - if (parsed && typeof parsed === "object") { - return parsed as GridStateMap; - } - return {}; - } catch (error: any) { - if (error && (error.code === "ENOENT" || error.code === "ENOTDIR")) { - return {}; - } - throw error; - } -} - -export async function loadGridState(symbol: string): Promise { - const map = await readStateFile(); - const snapshot = map[symbol]; - return snapshot ?? null; -} - -export async function saveGridState(snapshot: StoredGridState): Promise { - await ensureDataDir(); - const map = await readStateFile(); - map[snapshot.symbol] = snapshot; - await fs.writeFile(GRID_FILE, JSON.stringify(map, null, 2), "utf8"); -} - -export async function clearGridState(symbol: string): Promise { - const map = await readStateFile(); - if (!Object.prototype.hasOwnProperty.call(map, symbol)) { - return; - } - delete map[symbol]; - const entries = Object.keys(map); - if (!entries.length) { - try { - await fs.unlink(GRID_FILE); - } catch (error: any) { - if (!error || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) { - throw error; - } - } - return; - } - await ensureDataDir(); - await fs.writeFile(GRID_FILE, JSON.stringify(map, null, 2), "utf8"); -} diff --git a/src/strategy/grid-engine.ts b/src/strategy/grid-engine.ts index 9a328ee..e407156 100644 --- a/src/strategy/grid-engine.ts +++ b/src/strategy/grid-engine.ts @@ -1,36 +1,33 @@ -import type { GridConfig, GridDirection } from "../config"; +import type { GridConfig } from "../config"; import type { ExchangeAdapter } from "../exchanges/adapter"; import type { AsterAccountSnapshot, AsterDepth, AsterOrder, AsterTicker } from "../exchanges/types"; +import { routeLimitOrder, routeMarketOrder } from "../exchanges/order-router"; import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; -import { decimalsOf } from "../utils/math"; +import { decimalsOf, formatPriceToString, roundDownToTick, roundQtyDownToStep } from "../utils/math"; import { extractMessage } from "../utils/errors"; import { getMidOrLast } from "../utils/price"; import { getPosition, type PositionSnapshot } from "../utils/strategy"; -import { - placeMarketOrder, - placeOrder, - unlockOperating, - type OrderLockMap, - type OrderPendingMap, - type OrderTimerMap, -} from "../core/order-coordinator"; import { StrategyEventEmitter } from "./common/event-emitter"; import { safeSubscribe, type LogHandler } from "./common/subscriptions"; +interface GridLevelState { + index: number; + price: number; + side: "BUY" | "SELL"; + status: "idle" | "entry-working" | "position-open" | "exit-working"; + entryOrderId?: string; + exitOrderId?: string; + blockedUntil?: number; + entryClientId?: string; + exitClientId?: string; +} + interface DesiredGridOrder { level: number; side: "BUY" | "SELL"; price: string; amount: number; - intent?: "ENTRY" | "EXIT"; -} - -interface LevelMeta { - index: number; - price: number; - side: "BUY" | "SELL"; - closeTarget: number | null; - closeSources: number[]; + intent: "ENTRY" | "EXIT"; } interface GridLineSnapshot { @@ -44,17 +41,16 @@ interface GridLineSnapshot { export interface GridEngineSnapshot { ready: boolean; symbol: string; - lowerPrice: number; - upperPrice: number; + centerPrice: number | null; + lowerPrice: number | null; + upperPrice: number | null; lastPrice: number | null; - midPrice: number | null; gridLines: GridLineSnapshot[]; desiredOrders: DesiredGridOrder[]; openOrders: AsterOrder[]; position: PositionSnapshot; running: boolean; stopReason: string | null; - direction: GridDirection; tradeLog: TradeLogEntry[]; feedStatus: { account: boolean; @@ -72,44 +68,14 @@ interface EngineOptions { now?: () => number; } +const FINAL_STATUSES = new Set(["FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"]); const EPSILON = 1e-8; export class GridEngine { private readonly tradeLog: ReturnType; private readonly events = new StrategyEventEmitter(); - private readonly locks: OrderLockMap = {}; - private readonly timers: OrderTimerMap = {}; - private readonly pendings: OrderPendingMap = {}; - private priceDecimals: number; + private readonly priceDecimals: number; private readonly now: () => number; - private readonly configValid: boolean; - private readonly gridLevels: number[]; - 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(); - - 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 pendingKeyUntil = new Map(); - static readonly PENDING_TTL_MS = 10_000; - - private sidesLocked = false; - private startupCleaned = false; - private startupCancelDone = false; - private startupCancelPromise: Promise | null = null; - private initialCloseHandled = false; - private lastAbsPositionAmt = 0; - private immediateCloseToPlace: Array<{ sourceLevel: number; targetLevel: number; side: "BUY" | "SELL"; price: string }> = []; private accountSnapshot: AsterAccountSnapshot | null = null; private depthSnapshot: AsterDepth | null = null; @@ -118,69 +84,42 @@ export class GridEngine { private position: PositionSnapshot = { positionAmt: 0, entryPrice: 0, unrealizedProfit: 0, markPrice: null }; private desiredOrders: DesiredGridOrder[] = []; + private levels: GridLevelState[] = []; + private readonly orderIntentById = new Map(); + private readonly orderIntentByClientId = new Map(); + private readonly pendingCancels = new Set(); - private readonly feedArrived = { + private gridReady = false; + private running = true; + private stopReason: string | null = null; + private centerPrice: number | null = null; + private lowerPrice: number | null = null; + private upperPrice: number | null = null; + private gridSpacing: number | null = null; + private lastPrice: number | null = null; + private lastUpdated: number | null = null; + + private feedStatus = { account: false, orders: false, depth: false, ticker: false, }; - private readonly feedStatus = { - account: false, - orders: false, - depth: false, - ticker: false, - }; - - private readonly log: LogHandler; - private precisionSync: Promise | null = null; - private timer: ReturnType | null = null; private processing = false; - private running: boolean; - private stopReason: string | null = null; - 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 maxOpenOrderHitUntil: number | null = null; 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.priceDecimals = Math.max(0, decimalsOf(this.config.priceTick)); this.now = options.now ?? Date.now; - this.configValid = this.validateConfig(); - this.gridLevels = this.computeGridLevels(); - this.buildLevelMeta(); - this.syncPrecision(); - this.running = this.configValid; - if (!this.configValid) { - this.stopReason = "配置无效,已暂停网格"; - this.log("error", this.stopReason); - } - if (this.gridLevels.length === 0) { - this.running = false; - this.stopReason = `网格价位计算失败,模式不支持或参数无效: ${String(this.config.gridMode)}`; - this.log("error", this.stopReason); - this.emitUpdate(); - } this.bootstrap(); } start(): void { - if (this.timer || !this.running) { - if (!this.timer && !this.running) { - this.emitUpdate(); - } - return; - } - this.timer = setInterval(() => { - void this.tick(); - }, this.config.refreshIntervalMs); + if (this.timer) return; + this.timer = setInterval(() => void this.tick(), this.config.refreshIntervalMs); } stop(): void { @@ -202,74 +141,6 @@ export class GridEngine { return this.buildSnapshot(); } - private syncPrecision(): void { - if (this.precisionSync) return; - const getPrecision = this.exchange.getPrecision?.bind(this.exchange); - if (!getPrecision) return; - this.precisionSync = getPrecision() - .then((precision) => { - if (!precision) return; - let updated = false; - if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) { - if (Math.abs(precision.priceTick - this.config.priceTick) > 1e-12) { - this.config.priceTick = precision.priceTick; - this.priceDecimals = decimalsOf(precision.priceTick); - updated = true; - } - } - if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) { - if (Math.abs(precision.qtyStep - this.config.qtyStep) > 1e-12) { - this.config.qtyStep = precision.qtyStep; - updated = true; - } - } - if (updated) { - this.log( - "info", - `已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}` - ); - this.rebuildGridAfterPrecisionUpdate(); - } - }) - .catch((error) => { - this.log("error", `同步精度失败: ${extractMessage(error)}`); - this.precisionSync = null; - setTimeout(() => this.syncPrecision(), 2000); - }); - } - - private rebuildGridAfterPrecisionUpdate(): void { - if (!this.configValid) return; - const reference = this.getReferencePrice(); - const newLevels = this.computeGridLevels(); - this.gridLevels.length = 0; - this.gridLevels.push(...newLevels); - this.buildLevelMeta(reference); - this.emitUpdate(); - } - - 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 < 100) { - return false; - } - return true; - } - private bootstrap(): void { const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail); @@ -278,15 +149,10 @@ export class GridEngine { (snapshot) => { this.accountSnapshot = snapshot; this.position = getPosition(snapshot, this.config.symbol); - this.accountVersion += 1; - this.lastAbsPositionAmt = Math.abs(this.position.positionAmt); - if (!this.feedArrived.account) { - this.feedArrived.account = true; + if (!this.feedStatus.account) { + this.feedStatus.account = true; log("info", "账户快照已同步"); } - this.feedStatus.account = true; - this.tryLockSidesOnce(); - this.tryHandleInitialClose(); this.emitUpdate(); }, log, @@ -299,20 +165,11 @@ export class GridEngine { safeSubscribe( this.exchange.watchOrders.bind(this.exchange), (orders) => { - this.openOrders = Array.isArray(orders) - ? orders.filter((order) => order.symbol === this.config.symbol) - : []; - this.synchronizeLocks(orders); - this.ordersVersion += 1; - if (!this.feedArrived.orders) { - this.feedArrived.orders = true; + this.syncOrdersFromFeed(orders); + if (!this.feedStatus.orders) { + this.feedStatus.orders = true; log("info", "订单快照已同步"); - // cancel all existing orders at startup per simplified rules - this.startupCancelPromise = this.cancelAllExistingOrdersOnStartup(); } - this.feedStatus.orders = true; - this.tryLockSidesOnce(); - this.tryHandleInitialClose(); this.emitUpdate(); }, log, @@ -326,12 +183,10 @@ export class GridEngine { this.exchange.watchDepth.bind(this.exchange, this.config.symbol), (depth) => { this.depthSnapshot = depth; - if (!this.feedArrived.depth) { - this.feedArrived.depth = true; + if (!this.feedStatus.depth) { + this.feedStatus.depth = true; log("info", "盘口深度已同步"); } - this.feedStatus.depth = true; - this.tryLockSidesOnce(); }, log, { @@ -344,13 +199,11 @@ export class GridEngine { this.exchange.watchTicker.bind(this.exchange, this.config.symbol), (ticker) => { this.tickerSnapshot = ticker; - if (!this.feedArrived.ticker) { - this.feedArrived.ticker = true; + this.lastPrice = this.getReferencePrice(); + if (!this.feedStatus.ticker) { + this.feedStatus.ticker = true; log("info", "行情推送已同步"); } - this.feedStatus.ticker = true; - this.tryLockSidesOnce(); - this.tryHandleInitialClose(); this.emitUpdate(); }, log, @@ -361,51 +214,70 @@ export class GridEngine { ); } - private synchronizeLocks(orders: AsterOrder[] | null | undefined): void { - const list = Array.isArray(orders) ? orders : []; - const FINAL = new Set(["FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"]); - Object.keys(this.pendings).forEach((type) => { - const pendingId = this.pendings[type]; - if (!pendingId) return; - const match = list.find((order) => String(order.orderId) === pendingId); - if (!match) { - unlockOperating(this.locks, this.timers, this.pendings, type); - return; + private syncOrdersFromFeed(orders: AsterOrder[] | null | undefined): void { + const list = Array.isArray(orders) ? orders.filter((o) => o.symbol === this.config.symbol) : []; + this.openOrders = list; + const currentIds = new Set(list.map((o) => String(o.orderId))); + + for (const order of list) { + const status = String(order.status ?? "").toUpperCase(); + if (FINAL_STATUSES.has(status)) { + this.handleOrderResolution(String(order.orderId), status, order); } - const status = String(match.status || "").toUpperCase(); - if (FINAL.has(status)) { - unlockOperating(this.locks, this.timers, this.pendings, type); - } - }); + } + + for (const [orderId, meta] of [...this.orderIntentById.entries()]) { + if (currentIds.has(orderId)) continue; + const assumedStatus = this.pendingCancels.has(orderId) ? "CANCELED" : "FILLED"; + this.handleOrderResolution(orderId, assumedStatus, undefined, meta); + } + + for (const [clientId, meta] of [...this.orderIntentByClientId.entries()]) { + const exists = list.some((o) => o.clientOrderId && String(o.clientOrderId) === clientId); + if (exists) continue; + const assumedStatus = this.pendingCancels.has(clientId) ? "CANCELED" : "FILLED"; + this.handleOrderResolution(clientId, assumedStatus, undefined, meta); + } + + this.rebuildLevelAssignmentsFromOrders(); } private async tick(): Promise { if (this.processing) return; this.processing = true; try { - this.tryLockSidesOnce(); - this.tryHandleInitialClose(); if (!this.running) { - await this.tryRestart(); + this.emitUpdate(); return; } if (!this.isReady()) { + this.emitUpdate(); return; } const price = this.getReferencePrice(); + this.lastPrice = price; if (!Number.isFinite(price) || price === null) { + this.emitUpdate(); return; } - if (this.shouldStop(price)) { - await this.haltGrid(price); + if (!this.gridReady) { + this.buildGrid(price); + } + + if (this.lowerPrice != null && this.upperPrice != null && this.shouldStop(price)) { + await this.stopAndFlatten(price); + this.emitUpdate(); return; } - await this.syncGridSimple(price); + + this.desiredOrders = this.buildDesiredOrders(); + await this.syncOpenOrders(); + this.lastUpdated = this.now(); + this.emitUpdate(); } catch (error) { - this.log("error", `网格轮询异常: ${extractMessage(error)}`); + this.tradeLog.push("error", `网格轮询异常: ${extractMessage(error)}`); } finally { this.processing = false; - this.emitUpdate(); } } @@ -417,737 +289,450 @@ export class GridEngine { return getMidOrLast(this.depthSnapshot, this.tickerSnapshot); } + private buildGrid(referencePrice: number): void { + const spacingRaw = Math.max(this.config.priceTick, referencePrice * this.config.spacingPct); + const spacing = Math.max(this.config.priceTick, Number(formatPriceToString(spacingRaw, this.priceDecimals))); + const center = Number(formatPriceToString(referencePrice, this.priceDecimals)); + const lower = Number(formatPriceToString(center - spacing * this.config.levelsPerSide, this.priceDecimals)); + const upper = Number(formatPriceToString(center + spacing * this.config.levelsPerSide, this.priceDecimals)); - private tryLockSidesOnce(): void { - if (this.sidesLocked) return; - if (!this.feedStatus.ticker && !this.feedStatus.depth) return; - const anchor = this.chooseAnchoringPrice(); - if (!Number.isFinite(anchor) || anchor == null) return; - const price = this.clampReferencePrice(Number(anchor)); - this.buildLevelMeta(price); - this.sidesLocked = true; - this.log("info", "已根据锚定价一次性划分买卖档位"); + this.gridSpacing = spacing; + this.centerPrice = center; + this.lowerPrice = lower; + this.upperPrice = upper; + this.levels = []; + + for (let i = -this.config.levelsPerSide; i <= this.config.levelsPerSide; i += 1) { + if (i === 0) continue; // skip center to avoid immediate self-cross + const price = this.clampPrice(center + spacing * i); + const side: "BUY" | "SELL" = i < 0 ? "BUY" : "SELL"; + this.levels.push({ index: i, price, side, status: "idle" }); + } + + this.gridReady = true; + this.tradeLog.push("info", `网格已基于 ${center} 初始化,步长 ${spacing},每侧 ${this.config.levelsPerSide} 格`); + + // 生成完网格后,让现有挂单映射到网格,避免重复补单 + this.rebuildLevelAssignmentsFromOrders(); } - private clampReferencePrice(price: number): number { - if (!this.gridLevels.length) return price; - const minLevel = this.gridLevels[0]!; - const maxLevel = this.gridLevels[this.gridLevels.length - 1]!; - return Math.min(Math.max(price, minLevel), maxLevel); + private clampPrice(value: number): number { + const rounded = roundDownToTick(value, this.config.priceTick); + return Number(formatPriceToString(rounded, this.priceDecimals)); } - - // removed unused hasActiveOrders - - private deferPositionAlignment(): void {} - private shouldStop(price: number): boolean { - if (this.config.stopLossPct <= 0) return false; - const lowerTrigger = this.config.lowerPrice * (1 - this.config.stopLossPct); - const upperTrigger = this.config.upperPrice * (1 + this.config.stopLossPct); - if (price <= lowerTrigger) { - this.stopReason = `价格跌破网格下边界 ${((1 - price / this.config.lowerPrice) * 100).toFixed(2)}%`; + if (this.lowerPrice == null || this.upperPrice == null) return false; + const lowerGuard = this.lowerPrice * (1 - this.config.stopLossBufferPct); + const upperGuard = this.upperPrice * (1 + this.config.stopLossBufferPct); + if (price <= lowerGuard) { + this.stopReason = `价格跌破网格下界 ${(100 * (1 - price / this.lowerPrice)).toFixed(2)}%`; return true; } - if (price >= upperTrigger) { - this.stopReason = `价格突破网格上边界 ${((price / this.config.upperPrice - 1) * 100).toFixed(2)}%`; + if (price >= upperGuard) { + this.stopReason = `价格突破网格上界 ${(100 * (price / this.upperPrice - 1)).toFixed(2)}%`; return true; } return false; } - private async haltGrid(price: number): Promise { + private async stopAndFlatten(_price: number): Promise { if (!this.running) return; - const reason = this.stopReason ?? "触发网格止损"; - this.log("warn", `${reason},开始执行平仓与撤单`); + this.running = false; + const reason = this.stopReason ?? "触发止损"; + this.tradeLog.push("warn", `${reason},开始撤单并平仓`); try { await this.exchange.cancelAllOrders({ symbol: this.config.symbol }); - this.log("order", "已撤销全部网格挂单"); + this.tradeLog.push("order", "已撤销全部网格挂单"); } catch (error) { - this.log("error", `撤销网格挂单失败: ${extractMessage(error)}`); + this.tradeLog.push("error", `撤销挂单失败: ${extractMessage(error)}`); } await this.closePosition(); - this.desiredOrders = []; - this.lastUpdated = this.now(); - this.running = false; - this.pendingLongLevels.clear(); - this.pendingShortLevels.clear(); - this.awaitingByLevel.clear(); - this.closeKeyBySourceLevel.clear(); - this.immediateCloseToPlace = []; - // 仅在不需要自动重启时停止轮询定时器 - if (!this.config.autoRestart) { - this.stop(); + this.orderIntentById.clear(); + this.pendingCancels.clear(); + for (const level of this.levels) { + level.entryOrderId = undefined; + level.exitOrderId = undefined; + level.status = "idle"; } } private async closePosition(): Promise { const qty = this.position.positionAmt; if (!Number.isFinite(qty) || Math.abs(qty) < EPSILON) return; - const side = qty > 0 ? "SELL" : "BUY"; - const amount = Math.abs(qty); + const side: "BUY" | "SELL" = qty > 0 ? "SELL" : "BUY"; + const quantity = roundQtyDownToStep(Math.abs(qty), this.config.qtyStep); + if (quantity <= 0) return; try { - await placeMarketOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pendings, + await routeMarketOrder({ + adapter: this.exchange, + symbol: this.config.symbol, side, - amount, - this.log, - false, - undefined, - { qtyStep: this.config.qtyStep } - ); - this.log("order", `市价平仓 ${side} ${amount}`); + quantity, + reduceOnly: true, + closePosition: true, + }); + this.tradeLog.push("order", `市价止损平仓 ${side} ${quantity}`); } catch (error) { - this.log("error", `平仓失败: ${extractMessage(error)}`); - } finally { - unlockOperating(this.locks, this.timers, this.pendings, "MARKET"); + this.tradeLog.push("error", `平仓失败: ${extractMessage(error)}`); } } - private async tryRestart(): Promise { - if (!this.config.autoRestart || !this.configValid) return; - if (!this.isReady()) return; - if (this.config.restartTriggerPct <= 0) return; - const price = this.getReferencePrice(); - 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; - } - this.log("info", "价格重新回到网格区间,恢复网格运行"); - this.running = true; - this.stopReason = null; - // 重新锚定买卖侧(可选:根据当前价格) - this.sidesLocked = false; - this.tryLockSidesOnce(); - this.start(); - } - - private async syncGridSimple(price: number): Promise { - // 启动撤单未完成前,禁止铺网/下单,避免新单被启动撤单冲掉造成“消失待判定” - if (!this.startupCancelDone) { - this.log("info", "启动撤单未完成,等待后再部网"); - this.lastUpdated = this.now(); - return; - } - // --- 0) If there is an existing net position, enforce "exit-first" before any ENTRY --- - 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(); - 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 - 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) { - const id = String(o.orderId); - currIds.add(id); - const meta = this.orderIntentById.get(id); - const priceStr = this.normalizePrice(o.price); - if (meta) { - 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 - const kEntry = this.getOrderKey(o.side, priceStr, "ENTRY"); - const kExit = this.getOrderKey(o.side, priceStr, "EXIT"); - activeKeyCounts.set(kEntry, (activeKeyCounts.get(kEntry) ?? 0) + 1); - activeKeyCounts.set(kExit, (activeKeyCounts.get(kExit) ?? 0) + 1); - } - } - - // Any key that is already visible as active should clear local suppression - for (const [k, cnt] of activeKeyCounts.entries()) { - if ((cnt ?? 0) > 0) this.pendingKeyUntil.delete(k); - } - - // Detect disappeared orders by id - const disappeared: string[] = []; - for (const id of this.prevActiveIds) { - if (!currIds.has(id)) disappeared.push(id); - } - - // Handle disappeared orders classification and reactions - 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) { - const status = String(rec.status || "").toUpperCase(); - const executed = Number(rec.executedQty || 0); - if (status === "FILLED" || executed > EPSILON) { - classified = "filled"; - } else if (["CANCELED", "CANCELLED", "EXPIRED", "REJECTED"].includes(status)) { - 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.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.orderIntentById.delete(id); - continue; - } - if (classified === "filled") { - 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"); - const count = activeKeyCounts.get(exitKey) ?? 0; - if (count < 1) { - this.immediateCloseToPlace.push({ sourceLevel: meta.level, targetLevel: target0, side: side0, price: priceStr0 }); - } - this.closeKeyBySourceLevel.set(meta.level, exitKey); - } - } 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); - } - // 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 - if (meta.intent === "EXIT") { - const src = meta.sourceLevel ?? this.findSourceForCloseTarget(meta.level, meta.side); - this.closeKeyBySourceLevel.delete(src); - } - // Clear suppression for this key on cancel - 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 - 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; - } - 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); - 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); - this.awaitingByLevel.delete(level); - continue; - } - // no abs change after new account snapshot -> treat as canceled/no-op - this.awaitingByLevel.delete(level); - } - } - - // Desired open orders according to locked sides + private buildDesiredOrders(): DesiredGridOrder[] { + if (!this.gridReady || this.gridSpacing == null) return []; 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 - 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; - } - const count = plannedKeyCounts.get(key) ?? 0; - if (count < 1 && !desiredKeySet.has(key)) { - desired.push({ level: item.targetLevel, side: item.side, price: item.price, amount: this.config.orderSize, intent: "EXIT" }); - 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 + for (const level of this.levels) { + if (level.blockedUntil && this.now() < level.blockedUntil) { continue; } - 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 - } - 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 - const exitKeySame = this.getOrderKey("BUY", priceStr, "EXIT"); - 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 ((plannedKeyCounts.get(key) ?? 0) >= 1) continue; - if (!desiredKeySet.has(key)) { - desired.push({ level, side: "BUY", price: priceStr, amount: this.config.orderSize, intent: "ENTRY" }); - desiredKeySet.add(key); - plannedKeyCounts.set(key, (plannedKeyCounts.get(key) ?? 0) + 1); - } - } - - // ENTRY opens above price (SELL) - for (const level of this.sellLevelIndices) { - if (hasNetLong || hasNetShort) { - // During exit-first, do not place any ENTRY orders - 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; - } - 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; - } - const until = this.pendingKeyUntil.get(key); - const nowTs = this.now(); - if (until && until > nowTs) { - 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" }); - desiredKeySet.add(key); - plannedKeyCounts.set(key, (plannedKeyCounts.get(key) ?? 0) + 1); - } - } - - // 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" }); - 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" }); - 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) - 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(); - 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 - if (intent === "EXIT") { - const capped = this.capExitQty(d.amount, d.side); - if (capped <= EPSILON) continue; - d.amount = capped; + if (level.status === "position-open" || level.status === "exit-working") { + const exitPrice = this.computeExitPrice(level); + const priceStr = formatPriceToString(exitPrice, this.priceDecimals); + desired.push({ + level: level.index, + side: level.side === "BUY" ? "SELL" : "BUY", + price: priceStr, + amount: this.config.tradeAmount, + intent: "EXIT", + }); } 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; + const entryPrice = formatPriceToString(level.price, this.priceDecimals); + desired.push({ + level: level.index, + side: level.side, + price: entryPrice, + amount: this.config.tradeAmount, + intent: "ENTRY", + }); + } + } + return desired; + } + + private computeExitPrice(level: GridLevelState): number { + if (this.gridSpacing == null) return level.price; + const delta = Math.max(this.config.priceTick, this.gridSpacing); + const raw = level.side === "BUY" ? level.price + delta : level.price - delta; + const bumped = level.side === "BUY" ? Math.max(raw, level.price + this.config.priceTick) : Math.min(raw, level.price - this.config.priceTick); + return this.clampPrice(bumped); + } + + private async syncOpenOrders(): Promise { + const nowTs = this.now(); + if (this.maxOpenOrderHitUntil && nowTs < this.maxOpenOrderHitUntil) { + this.tradeLog.push("info", "命中交易所挂单上限冷却,暂不补单"); + return; + } + const desiredKeys = new Map(); + for (const order of this.desiredOrders) { + const normalizedPrice = this.clampPrice(Number(order.price)); + const normalizedOrder = { ...order, price: formatPriceToString(normalizedPrice, this.priceDecimals) }; + desiredKeys.set(this.orderKey(normalizedOrder), normalizedOrder); + } + + const activeKeys = new Set(); + for (const order of this.openOrders) { + if (order.symbol !== this.config.symbol) continue; + const status = String(order.status ?? "").toUpperCase(); + if (FINAL_STATUSES.has(status)) continue; + const priceStr = formatPriceToString(this.clampPrice(Number(order.price ?? 0)), this.priceDecimals); + const meta = this.orderIntentById.get(String(order.orderId)) + ?? (order.clientOrderId ? this.orderIntentByClientId.get(String(order.clientOrderId)) : undefined); + let intent = meta?.intent; + let levelIdx = meta?.level; + if (!intent || levelIdx == null) { + const parsedFromClient = order.clientOrderId ? this.parseClientOrderId(String(order.clientOrderId)) : null; + if (parsedFromClient) { + intent = parsedFromClient.intent; + levelIdx = parsedFromClient.level; + } + const match = this.matchLevelForOrder(Number(order.price ?? 0), order.side === "SELL" ? "SELL" : "BUY", (a, b) => Math.abs(a - b) <= Math.max(this.config.priceTick * 0.5, 1e-9)); + if (match) { + intent = match.intent; + levelIdx = match.level.index; } - 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; + const side = order.side === "SELL" ? "SELL" : "BUY"; + const key = this.orderKey({ intent: intent ?? "ENTRY", price: priceStr, side, level: levelIdx ?? 0, amount: 0 }); + if (desiredKeys.has(key)) { + activeKeys.add(key); + } else { + await this.cancelOrder(order); } - try { - // record attempt time to avoid rapid retries even if placement fails - this.lastLimitAttemptAt = nowTs2; - const placed = await placeOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pendings, - d.side, - d.price, - d.amount, - this.log, - false, - undefined, - { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep, skipDedupe: true } - ); - 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 } = { - side: d.side, - price: d.price, - level: d.level, - intent, - }; - if (intent === "EXIT") { - record.sourceLevel = this.findSourceForCloseTarget(d.level, d.side); - } - this.orderIntentById.set(String(placed.orderId), record); + } + + for (const order of this.desiredOrders) { + const normalizedPrice = this.clampPrice(Number(order.price)); + const normalizedOrder = { ...order, price: formatPriceToString(normalizedPrice, this.priceDecimals) }; + const key = this.orderKey(normalizedOrder); + if (activeKeys.has(key)) continue; + await this.placeGridOrder(normalizedOrder); + } + } + + private async cancelOrder(order: AsterOrder): Promise { + if (!order || order.orderId == null) return; + const orderId = String(order.orderId); + if (this.pendingCancels.has(orderId)) return; + try { + this.pendingCancels.add(orderId); + await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId }); + this.tradeLog.push("order", `撤销多余挂单 #${orderId}`); + } catch (error) { + this.tradeLog.push("error", `撤单失败 #${orderId}: ${extractMessage(error)}`); + } + } + + private async placeGridOrder(target: DesiredGridOrder): Promise { + const quantity = roundQtyDownToStep(Math.abs(target.amount), this.config.qtyStep); + if (!Number.isFinite(quantity) || quantity <= 0) { + this.tradeLog.push("error", "下单数量无效,跳过网格单"); + return; + } + const priceNum = Number(target.price); + if (!Number.isFinite(priceNum) || priceNum <= 0) return; + const clientId = this.buildClientOrderId(target); + try { + const placed = await routeLimitOrder({ + adapter: this.exchange, + symbol: this.config.symbol, + side: target.side, + price: priceNum, + quantity, + timeInForce: "GTX", + clientOrderId: clientId, + }); + if (placed && placed.orderId != null) { + const orderId = String(placed.orderId); + this.orderIntentById.set(orderId, { + level: target.level, + intent: target.intent, + side: target.side, + price: target.price, + clientId, + }); + const returnedClientId = (placed as any).clientOrderId || (placed as any).client_order_id; + if (returnedClientId) { + this.orderIntentByClientId.set(String(returnedClientId), { + level: target.level, + intent: target.intent, + side: target.side, + price: target.price, + clientId: String(returnedClientId), + }); + } + const level = this.levels.find((lv) => lv.index === target.level); + if (level) { + if (target.intent === "ENTRY") { + level.status = "entry-working"; + level.entryOrderId = orderId; + level.entryClientId = String(returnedClientId ?? clientId); + } else { + level.status = "exit-working"; + level.exitOrderId = orderId; + level.exitClientId = String(returnedClientId ?? clientId); } } - // 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.tradeLog.push("order", `${target.intent === "ENTRY" ? "挂开仓" : "挂平仓"} ${target.side} @ ${target.price}`); + } + } catch (error) { + this.tradeLog.push("error", `挂单失败 (${target.side} @ ${target.price}): ${extractMessage(error)}`); + const level = this.levels.find((lv) => lv.index === target.level); + const message = String(error ?? "").toLowerCase(); + const isOpenLimit = message.includes("max open order") || message.includes("open orders exceeded"); + const backoff = isOpenLimit ? 30_000 : 5_000; + if (level) { + level.blockedUntil = this.now() + backoff; + } + if (isOpenLimit) { + this.maxOpenOrderHitUntil = this.now() + backoff; } } - - this.lastUpdated = this.now(); - // Update last observed absolute position amount for next disappearance classification - this.lastAbsPositionAmt = Math.abs(this.position.positionAmt); } - 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 - const meta = this.orderIntentById.get(String(o.orderId)); - if (!meta || meta.intent !== "EXIT") continue; - const orig = Number(o.origQty || 0); - const exec = Number(o.executedQty || 0); - const remaining = Math.max(orig - exec, 0); - pendingExitQty += remaining; - } - 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 - if (side === "SELL") { - // closing long: find a BUY source that maps to targetLevel - for (const meta of this.levelMeta) { - if (meta.side === "BUY" && meta.closeTarget === targetLevel && this.pendingLongLevels.has(meta.index)) { - return meta.index; - } + private handleOrderResolution(orderId: string, status: string, order?: AsterOrder, fallbackMeta?: { + level: number; + intent: "ENTRY" | "EXIT"; + side: "BUY" | "SELL"; + price: string; + }): void { + const meta = fallbackMeta ?? this.orderIntentById.get(orderId) ?? this.orderIntentByClientId.get(orderId); + if (!meta) return; + const level = this.levels.find((lv) => lv.index === meta.level); + if (!level) return; + const priceStr = meta.price ?? formatPriceToString(Number(order?.price ?? 0), this.priceDecimals); + if (meta.intent === "ENTRY") { + level.entryOrderId = undefined; + if (level.entryClientId === orderId) level.entryClientId = undefined; + if (status === "FILLED") { + level.status = "position-open"; + this.tradeLog.push("fill", `网格开仓成交 ${meta.side} @ ${priceStr} (#${meta.level})`); + } else { + level.status = "idle"; } } else { - for (const meta of this.levelMeta) { - if (meta.side === "SELL" && meta.closeTarget === targetLevel && this.pendingShortLevels.has(meta.index)) { - return meta.index; + level.exitOrderId = undefined; + if (level.exitClientId === orderId) level.exitClientId = undefined; + if (status === "FILLED") { + level.status = "idle"; + this.tradeLog.push("fill", `网格平仓成交 ${meta.side} @ ${priceStr} (#${meta.level})`); + } else { + level.status = "position-open"; + } + } + this.orderIntentById.delete(orderId); + this.orderIntentByClientId.delete(orderId); + if (meta.clientId) { + this.orderIntentByClientId.delete(meta.clientId); + this.orderIntentById.delete(meta.clientId); + } + this.pendingCancels.delete(orderId); + } + + private orderKey(order: Pick): string { + return `${order.intent}:${order.side}:${order.price}:${order.level}`; + } + + private rebuildLevelAssignmentsFromOrders(): void { + if (!this.gridReady || !this.levels.length) return; + // Reset derived state + for (const level of this.levels) { + level.entryOrderId = undefined; + level.exitOrderId = undefined; + level.entryClientId = undefined; + level.exitClientId = undefined; + if (level.status === "entry-working" || level.status === "exit-working") { + level.status = "idle"; + } + } + + const priceMatch = (a: number, b: number): boolean => { + const tolerance = Math.max(this.config.priceTick * 0.55, 1e-8); + return Math.abs(a - b) <= tolerance; + }; + + let unmatched = 0; + for (const order of this.openOrders) { + const status = String(order.status ?? "").toUpperCase(); + if (FINAL_STATUSES.has(status)) continue; + const price = Number(order.price ?? 0); + if (!Number.isFinite(price)) { + unmatched += 1; + continue; + } + const orderSide = order.side === "SELL" ? "SELL" : "BUY"; + const match = this.matchLevelForOrder(price, orderSide, priceMatch); + if (!match) { + unmatched += 1; + continue; + } + const { level, intent } = match; + const orderId = String(order.orderId ?? ""); + const clientId = order.clientOrderId ? String(order.clientOrderId) : undefined; + const keyMeta = { + level: level.index, + intent, + side: order.side === "SELL" ? "SELL" : "BUY", + price: formatPriceToString(price, this.priceDecimals), + clientId, + }; + this.orderIntentById.set(orderId, keyMeta); + if (clientId) { + this.orderIntentByClientId.set(clientId, keyMeta); + } + if (intent === "ENTRY") { + level.status = "entry-working"; + level.entryOrderId = orderId; + if (clientId) level.entryClientId = clientId; + } else { + level.status = "exit-working"; + level.exitOrderId = orderId; + if (clientId) level.exitClientId = clientId; + } + } + + if (unmatched > 0) { + this.tradeLog.push("info", `存在 ${unmatched} 笔挂单未能映射到网格价位,已跳过重复补单`); + } + } + + private buildClientOrderId(order: DesiredGridOrder): string { + const intentFlag = order.intent === "ENTRY" ? "1" : "2"; + const sideFlag = order.side === "BUY" ? "1" : "2"; + const signFlag = order.level < 0 ? "0" : "1"; + const levelCode = Math.abs(order.level).toString().padStart(3, "0"); + const ts = Date.now().toString().slice(-8); // tail 8 digits for brevity + // Digits-only clientId to satisfy exchanges that reject non-numeric IDs + return `${intentFlag}${sideFlag}${signFlag}${levelCode}${ts}`; + } + + private parseClientOrderId(clientId: string): { intent: "ENTRY" | "EXIT"; level: number } | null { + // Expect digits-only string: [intent][side][sign][level(3)][ts...] + if (!/^[0-9]{5,}$/.test(clientId)) return null; + const intentBit = clientId[0]; + const signBit = clientId[2]; + const levelCode = clientId.slice(3, 6); + const intent = intentBit === "1" ? "ENTRY" : intentBit === "2" ? "EXIT" : null; + const levelAbs = Number(levelCode); + if (!intent || !Number.isFinite(levelAbs)) return null; + const level = signBit === "0" ? -levelAbs : levelAbs; + return { intent, level }; + } + + private matchLevelForOrder( + price: number, + side: "BUY" | "SELL", + matcher: (a: number, b: number) => boolean + ): { level: GridLevelState; intent: "ENTRY" | "EXIT" } | null { + const normalizedPrice = this.clampPrice(price); + // Quick exact match by index map to avoid O(n^2) under high density + const primary = this.levels.find((lv) => matcher(this.clampPrice(lv.price), normalizedPrice)); + if (primary && primary.side === side) { + return { level: primary, intent: "ENTRY" }; + } + if (primary && primary.side !== side && matcher(this.clampPrice(this.computeExitPrice(primary)), normalizedPrice)) { + return { level: primary, intent: "EXIT" }; + } + for (const level of this.levels) { + // entry-side match: same side, price == level price + const levelPrice = this.clampPrice(level.price); + if (side === level.side && matcher(levelPrice, normalizedPrice)) { + return { level, intent: "ENTRY" }; + } + // exit-side match: opposite side, price == computed exit price + if (side !== level.side) { + const exitPrice = this.clampPrice(this.computeExitPrice(level)); + if (matcher(exitPrice, normalizedPrice)) { + return { level, intent: "EXIT" }; } } } - return targetLevel; // fallback - } - - private isTargetOfPendingLong(targetLevel: number): boolean { - for (const source of this.pendingLongLevels) { - if (this.levelMeta[source]?.closeTarget === targetLevel) return true; - } - return false; - } - - private isTargetOfPendingShort(targetLevel: number): boolean { - for (const source of this.pendingShortLevels) { - if (this.levelMeta[source]?.closeTarget === targetLevel) return true; - } - return false; - } - - private computeGridLevels(): number[] { - if (!this.configValid) return []; - const { lowerPrice, upperPrice, gridLevels } = this.config; - if (gridLevels <= 1) return [Number(lowerPrice.toFixed(this.priceDecimals)), Number(upperPrice.toFixed(this.priceDecimals))]; - if (this.config.gridMode === "geometric") { - const ratio = Math.pow(upperPrice / lowerPrice, 1 / (gridLevels - 1)); - const levels: number[] = []; - for (let i = 0; i < gridLevels; i += 1) { - 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)); - } - return levels; - } - this.log("error", `不支持的网格模式: ${String(this.config.gridMode)}`); - return []; + return null; } 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")) - ); - const openOrderKeys = new Set( - this.openOrders - .filter((order) => this.isActiveLimitOrder(order)) - .map((order) => { - const id = String(order.orderId); - const meta = this.orderIntentById.get(id); - const intent: "ENTRY" | "EXIT" = meta?.intent ?? "ENTRY"; - return this.getOrderKey(order.side, this.normalizePrice(order.price), intent); - }) - ); - - const gridLines: GridLineSnapshot[] = this.gridLevels.map((price, level) => { - const 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 hasOrder = key ? openOrderKeys.has(key) : false; - const active = Boolean(desired && key && desiredKeys.has(key)); - return { - level, - price, - side, - active, - hasOrder, - }; - }); + const gridLines: GridLineSnapshot[] = this.levels.map((level) => ({ + level: level.index, + price: level.price, + side: level.side, + active: this.running, + hasOrder: level.status === "entry-working" || level.status === "exit-working", + })); return { - ready: this.isReady() && this.running, + ready: this.gridReady && this.isReady(), symbol: this.config.symbol, - lowerPrice: this.config.lowerPrice, - upperPrice: this.config.upperPrice, - lastPrice, - midPrice, + centerPrice: this.centerPrice, + lowerPrice: this.lowerPrice, + upperPrice: this.upperPrice, + lastPrice: this.lastPrice, gridLines, - desiredOrders: this.desiredOrders.slice(), - openOrders: this.openOrders.filter((order) => this.isActiveLimitOrder(order)), + desiredOrders: this.desiredOrders, + openOrders: this.openOrders, position: this.position, running: this.running, - stopReason: this.running ? null : this.stopReason, - direction: this.config.direction, - tradeLog: this.tradeLog.all().slice(), + stopReason: this.stopReason, + tradeLog: this.tradeLog.all(), feedStatus: { ...this.feedStatus }, lastUpdated: this.lastUpdated, }; @@ -1156,226 +741,4 @@ export class GridEngine { private emitUpdate(): void { this.events.emit("update", this.buildSnapshot()); } - - private getOrderKey(side: "BUY" | "SELL", price: string, intent: "ENTRY" | "EXIT" = "ENTRY"): string { - return `${side}:${price}:${intent}`; - } - - private isActiveLimitOrder(o: AsterOrder): boolean { - if (o.symbol !== this.config.symbol) return false; - if (o.type !== "LIMIT") return false; - const s = String(o.status || "").toUpperCase(); - return !["FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"].includes(s); - } - - private normalizePrice(price: string | number): string { - const numeric = Number(price); - if (!Number.isFinite(numeric)) return "0"; - return numeric.toFixed(this.priceDecimals); - } - - private formatPrice(price: number): string { - if (!Number.isFinite(price)) return "0"; - 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; - this.sellLevelIndices.length = 0; - if (!this.gridLevels.length) return; - const pivotIndex = Math.floor(Math.max(this.gridLevels.length - 1, 0) / 2); - const hasReference = Number.isFinite(referencePrice ?? NaN); - const pivotPrice = hasReference ? this.clampReferencePrice(Number(referencePrice)) : null; - for (let i = 0; i < this.gridLevels.length; i += 1) { - let side: "BUY" | "SELL"; - if (pivotPrice != null) { - side = this.gridLevels[i]! <= pivotPrice + EPSILON ? "BUY" : "SELL"; - } else { - side = 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); - } - // 简化映射: - // - 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) { - if (this.levelMeta[j]!.side === "SELL") { - meta.closeTarget = this.levelMeta[j]!.index; - this.levelMeta[j]!.closeSources.push(meta.index); - break; - } - } - } 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 chooseAnchoringPrice(): number | null { - const reference = this.getReferencePrice(); - if (!Number.isFinite(reference) || reference == null) return null; - const ref = Number(reference); - const qty = this.position.positionAmt; - 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; - } - - - 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; - } } diff --git a/src/ui/GridApp.tsx b/src/ui/GridApp.tsx index 6d85f7f..12ee042 100644 --- a/src/ui/GridApp.tsx +++ b/src/ui/GridApp.tsx @@ -118,10 +118,10 @@ export function GridApp({ onExit }: GridAppProps) { Grid Strategy Dashboard - 交易所: {exchangeName} | 交易对: {snapshot.symbol} | 状态: {snapshot.running ? "运行中" : "暂停"} | 方向: {snapshot.direction} + 交易所: {exchangeName} | 交易对: {snapshot.symbol} | 状态: {snapshot.running ? "运行中" : "暂停"} - 实时价格: {formatNumber(snapshot.lastPrice, 4)} | 下界: {formatNumber(snapshot.lowerPrice, 4)} | 上界: {formatNumber(snapshot.upperPrice, 4)} | 网格数量: {snapshot.gridLines.length} + 实时价格: {formatNumber(snapshot.lastPrice, 4)} | 中心价: {formatNumber(snapshot.centerPrice, 4)} | 下界: {formatNumber(snapshot.lowerPrice, 4)} | 上界: {formatNumber(snapshot.upperPrice, 4)} | 网格数量: {snapshot.gridLines.length} 数据状态: {feedEntries.map((entry, index) => ( @@ -139,10 +139,10 @@ export function GridApp({ onExit }: GridAppProps) { 网格配置 - 单笔数量: {formatNumber(gridConfig.orderSize, 6)} | 最大仓位: {formatNumber(gridConfig.maxPositionSize, 6)} + 单笔数量: {formatNumber(gridConfig.tradeAmount, 6)} | 每侧格子数: {gridConfig.levelsPerSide} - 止损阈值: {(gridConfig.stopLossPct * 100).toFixed(2)}% | 重启阈值: {(gridConfig.restartTriggerPct * 100).toFixed(2)}% | 自动重启: {gridConfig.autoRestart ? "启用" : "关闭"} + 网格步长: {(gridConfig.spacingPct * 100).toFixed(3)}% | 止损缓冲: {(gridConfig.stopLossBufferPct * 100).toFixed(3)}% 刷新间隔: {gridConfig.refreshIntervalMs} ms