import type { MakerConfig } from "../config"; import type { ExchangeAdapter } from "../exchanges/adapter"; import type { AccountSnapshot, Depth, Kline, Order, Ticker, } from "../exchanges/types"; import { formatPriceToString } from "../utils/math"; import { createTradeLog } from "../logging/trade-log"; import { isUnknownOrderError, isRateLimitError } from "../utils/errors"; import { isOrderActiveStatus } from "../utils/order-status"; import { getPosition, parseSymbolParts } from "../utils/strategy"; import type { PositionSnapshot } from "../utils/strategy"; import { computeDepthStats } from "../utils/depth"; import { computePositionPnl } from "../utils/pnl"; import { getTopPrices, getPricesAtLevel, getMidOrLast } from "../utils/price"; import { shouldStopLoss } from "../utils/risk"; import { marketClose, placeOrder, unlockOperating, } from "../core/order-coordinator"; import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator"; import type { MakerEngineSnapshot } from "./maker-engine"; import { makeOrderPlan } from "../core/lib/order-plan"; import { safeCancelOrder } from "../core/lib/orders"; import { RateLimitController } from "../core/lib/rate-limit"; import { StrategyEventEmitter } from "./common/event-emitter"; import { safeSubscribe, type LogHandler } from "./common/subscriptions"; import { SessionVolumeTracker } from "./common/session-volume"; interface DesiredOrder { side: "BUY" | "SELL"; price: string; // 改为字符串价格 amount: number; reduceOnly: boolean; } export interface OffsetMakerEngineSnapshot extends MakerEngineSnapshot { buyDepthSum10: number; sellDepthSum10: number; depthImbalance: "balanced" | "buy_dominant" | "sell_dominant"; skipBuySide: boolean; skipSellSide: boolean; marketType?: "perp" | "spot"; baseAsset?: string | null; quoteAsset?: string | null; spotBalances?: { baseAvailable: number; quoteAvailable: number; baseWallet?: number } | null; } type MakerEvent = "update"; type MakerListener = (snapshot: OffsetMakerEngineSnapshot) => void; const EPS = 1e-5; export class OffsetMakerEngine { private accountSnapshot: AccountSnapshot | null = null; private depthSnapshot: Depth | null = null; private tickerSnapshot: Ticker | null = null; private lastKline: Kline | null = null; private liveCandle: { startMs: number; open: number; close: number } | null = null; private openOrders: Order[] = []; private readonly locks: OrderLockMap = {}; private readonly timers: OrderTimerMap = {}; private readonly pending: OrderPendingMap = {}; private readonly pendingCancelOrders = new Set(); private readonly tradeLog: ReturnType; private readonly events = new StrategyEventEmitter(); private readonly sessionVolume = new SessionVolumeTracker(); private priceTick: number = 0.1; private qtyStep: number = 0.001; private minBaseAmount: number | null = null; private minQuoteAmount: number | null = null; private precisionSync: Promise | null = null; private marketType: "perp" | "spot" = "perp"; private baseAsset: string | null = null; private quoteAsset: string | null = null; private baseAssetId: number | null = null; private quoteAssetId: number | null = null; private spotEntryPrice: number | null = null; private lastSpotWallet = 0; private spotKlineUp: boolean | null = null; private lastSpotBuyGuardLogged = false; private lastSpotStopSkipped = false; private timer: ReturnType | null = null; private processing = false; private desiredOrders: DesiredOrder[] = []; private accountUnrealized = 0; private initialOrderSnapshotReady = false; private initialOrderResetDone = false; private entryPricePendingLogged = false; private readonly rateLimit: RateLimitController; private lastBuyDepthSum10 = 0; private lastSellDepthSum10 = 0; private lastSkipBuy = false; private lastSkipSell = false; private lastImbalance: "balanced" | "buy_dominant" | "sell_dominant" = "balanced"; private lastBuyPriceViable = true; private lastSellPriceViable = true; private feedStatus = { account: false, depth: false, ticker: false, orders: false, }; // Reprice suppression for fast-ticking Lighter order book private readonly repriceDwellMs: number; private readonly minRepriceTicks: number = 2; private lastEntryOrderBySide: Record<"BUY" | "SELL", { price: string; ts: number } | null> = { BUY: null, SELL: null, }; constructor(private readonly config: MakerConfig, private readonly exchange: ExchangeAdapter) { this.tradeLog = createTradeLog(this.config.maxLogEntries); this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) => this.tradeLog.push(type, detail) ); this.priceTick = Math.max(1e-9, this.config.priceTick); this.qtyStep = Math.max(1e-9, this.qtyStep); const parsedSymbols = parseSymbolParts(this.config.symbol); this.baseAsset = parsedSymbols.base ?? null; this.quoteAsset = parsedSymbols.quote ?? null; this.syncPrecision(); // Debounce window defaults to 3x refresh interval, min 1s this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3); this.bootstrap(); } start(): void { if (this.timer) return; this.timer = setInterval(() => { void this.tick(); }, this.config.refreshIntervalMs); } stop(): void { if (this.timer) { clearInterval(this.timer); this.timer = null; } } on(event: MakerEvent, handler: MakerListener): void { this.events.on(event, handler); } off(event: MakerEvent, handler: MakerListener): void { this.events.off(event, handler); } getSnapshot(): OffsetMakerEngineSnapshot { return this.buildSnapshot(); } private bootstrap(): void { const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail); safeSubscribe( this.exchange.watchAccount.bind(this.exchange), (snapshot) => { this.accountSnapshot = snapshot; this.feedStatus.account = true; if (snapshot.marketType) { this.marketType = snapshot.marketType; } const parsed = parseSymbolParts(this.config.symbol); this.baseAsset = snapshot.baseAsset ?? this.baseAsset ?? parsed.base ?? null; this.quoteAsset = snapshot.quoteAsset ?? this.quoteAsset ?? parsed.quote ?? null; this.baseAssetId = snapshot.baseAssetId ?? this.baseAssetId; this.quoteAssetId = snapshot.quoteAssetId ?? this.quoteAssetId; const totalUnrealized = Number(snapshot.totalUnrealizedProfit ?? "0"); if (Number.isFinite(totalUnrealized)) { this.accountUnrealized = totalUnrealized; } const balances = this.getSpotBalances(snapshot); if (snapshot.marketType === "spot" || this.marketType === "spot") { const baseWallet = balances?.baseWallet ?? 0; if (baseWallet < EPS) { this.spotEntryPrice = null; } else if (baseWallet > this.lastSpotWallet + EPS) { const ref = this.getReferencePrice(); if (Number.isFinite(ref)) { this.spotEntryPrice = Number(ref); } } this.lastSpotWallet = baseWallet; } const position = getPosition(snapshot, this.config.symbol); if (this.marketType === "spot" && this.spotEntryPrice != null) { position.entryPrice = this.spotEntryPrice; } this.sessionVolume.update(position, this.getReferencePrice()); this.emitUpdate(); }, log, { subscribeFail: (error) => `订阅账户失败: ${String(error)}`, processFail: (error) => `账户推送处理异常: ${String(error)}`, } ); safeSubscribe( this.exchange.watchOrders.bind(this.exchange), (orders) => { this.syncLocksWithOrders(orders); this.feedStatus.orders = true; this.openOrders = Array.isArray(orders) ? orders.filter( (order) => order.type !== "MARKET" && order.symbol === this.config.symbol && isOrderActiveStatus(order.status) ) : []; const currentIds = new Set(this.openOrders.map((order) => String(order.orderId))); for (const id of Array.from(this.pendingCancelOrders)) { if (!currentIds.has(id)) { this.pendingCancelOrders.delete(id); } } this.initialOrderSnapshotReady = true; this.emitUpdate(); }, log, { subscribeFail: (error) => `订阅订单失败: ${String(error)}`, processFail: (error) => `订单推送处理异常: ${String(error)}`, } ); safeSubscribe( this.exchange.watchDepth.bind(this.exchange, this.config.symbol), (depth) => { this.depthSnapshot = depth; this.feedStatus.depth = true; this.emitUpdate(); }, log, { subscribeFail: (error) => `订阅深度失败: ${String(error)}`, processFail: (error) => `深度推送处理异常: ${String(error)}`, } ); safeSubscribe( this.exchange.watchTicker.bind(this.exchange, this.config.symbol), (ticker) => { this.tickerSnapshot = ticker; this.feedStatus.ticker = true; this.emitUpdate(); }, log, { subscribeFail: (error) => `订阅Ticker失败: ${String(error)}`, processFail: (error) => `价格推送处理异常: ${String(error)}`, } ); safeSubscribe( this.exchange.watchKlines.bind(this.exchange, this.config.symbol, "1m"), (klines) => { if (!Array.isArray(klines) || !klines.length) return; const latest = klines[klines.length - 1]; this.lastKline = latest; const open = Number(latest.open); const close = Number(latest.close); if (Number.isFinite(open) && Number.isFinite(close)) { this.spotKlineUp = close > open; } }, log, { subscribeFail: (error) => `订阅K线失败: ${String(error)}`, processFail: (error) => `K线推送处理异常: ${String(error)}`, } ); } private syncLocksWithOrders(orders: Order[] | null | undefined): void { const list = Array.isArray(orders) ? orders : []; Object.keys(this.pending).forEach((type) => { const pendingId = this.pending[type]; if (!pendingId) return; const match = list.find((order) => String(order.orderId) === pendingId); if (!match || (match.status && match.status !== "NEW" && match.status !== "PARTIALLY_FILLED")) { unlockOperating(this.locks, this.timers, this.pending, type); } }); } private isReady(): boolean { return Boolean(this.accountSnapshot && this.depthSnapshot); } private async tick(): Promise { if (this.processing) return; this.processing = true; let hadRateLimit = false; try { const decision = this.rateLimit.beforeCycle(); if (decision === "paused") { this.emitUpdate(); return; } if (decision === "skip") { return; } if (!this.isReady()) { this.emitUpdate(); return; } if (!(await this.ensureStartupOrderReset())) { this.emitUpdate(); return; } // 确保使用最新的深度数据 const depth = this.depthSnapshot!; const { topBid, topAsk } = getTopPrices(depth); if (topBid == null || topAsk == null) { this.emitUpdate(); return; } const { buySum, sellSum, skipBuySide, skipSellSide, imbalance } = this.evaluateDepth(depth); this.lastBuyDepthSum10 = buySum; this.lastSellDepthSum10 = sellSum; this.lastSkipBuy = skipBuySide; this.lastSkipSell = skipSellSide; this.lastImbalance = imbalance; const position = this.getPositionSnapshot(); const isSpotMarket = this.marketType === "spot"; const spotBalances = isSpotMarket ? this.getSpotBalances() : null; const balancesForSpot = isSpotMarket ? spotBalances ?? { baseAvailable: 0, quoteAvailable: 0 } : spotBalances; this.updateLiveCandle(); const handledImbalance = await this.handleImbalanceExit(position, buySum, sellSum); if (handledImbalance) { this.emitUpdate(); return; } // 在计算挂单价格前,重新获取最新的深度数据以确保价格同步 const latestDepth = this.depthSnapshot!; const { topBid: latestBid, topAsk: latestAsk } = getTopPrices(latestDepth); const finalBid = latestBid ?? topBid!; const finalAsk = latestAsk ?? topAsk!; // 直接使用orderbook价格,格式化为字符串避免精度问题 const priceDecimals = this.getPriceDecimals(); // 平仓价格始终使用买1/卖1 const closeBidPrice = formatPriceToString(finalBid, priceDecimals); const closeAskPrice = formatPriceToString(finalAsk, priceDecimals); // 开仓价格根据 entryDepthLevel 使用指定档位 const entryLevel = this.config.entryDepthLevel ?? 1; const { bidAtLevel: entryBid, askAtLevel: entryAsk } = getPricesAtLevel(latestDepth, entryLevel); const entryBidBase = entryBid ?? finalBid; const entryAskBase = entryAsk ?? finalAsk; const rawBidPrice = entryBidBase - this.config.bidOffset; const rawAskPrice = entryAskBase + this.config.askOffset; const safeBid = this.ensureMakerPrice("BUY", rawBidPrice, finalBid, finalAsk); const safeAsk = this.ensureMakerPrice("SELL", rawAskPrice, finalBid, finalAsk); const bidPrice = safeBid != null ? formatPriceToString(safeBid, priceDecimals) : null; const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null; const rawAbsPosition = Math.abs(position.positionAmt); const minSell = Number.isFinite(this.minBaseAmount) && this.minBaseAmount! > 0 ? this.minBaseAmount! : Math.max(this.config.tradeAmount, this.qtyStep); let absPosition = rawAbsPosition; const tinySpotPosition = isSpotMarket && minSell > 0 && rawAbsPosition > EPS && rawAbsPosition + EPS < minSell; if (tinySpotPosition) { absPosition = 0; // treat as flat to allow buys to accumulate until reaching minimum sell size } const desired: DesiredOrder[] = []; const canEnter = !this.rateLimit.shouldBlockEntries(); const allowSpotBuy = !isSpotMarket || this.isSpotKlineUp(); if (absPosition < EPS && isSpotMarket) { this.entryPricePendingLogged = false; const baseAvail = balancesForSpot?.baseAvailable ?? 0; const baseWallet = balancesForSpot?.baseWallet ?? baseAvail; const maxBase = Math.max(baseAvail, baseWallet); if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) { // 无法卖出,跳过卖单,允许买单累计 this.lastSellPriceViable = false; if (!skipSellSide) { this.tradeLog.push("info", "现货持仓低于最小卖单量,暂不挂卖单"); } } if (!skipBuySide && canEnter) { if (!allowSpotBuy) { if (this.lastBuyPriceViable) { this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单"); this.lastBuyPriceViable = false; } } else { const buyAmount = this.computeSpotOrderSize({ side: "BUY", desiredAmount: this.config.tradeAmount, price: bidPrice != null ? Number(bidPrice) : null, balances: balancesForSpot, }); if (bidPrice != null && buyAmount >= EPS) { this.lastBuyPriceViable = true; desired.push({ side: "BUY", price: bidPrice, amount: buyAmount, reduceOnly: false }); } else if (this.lastBuyPriceViable) { this.lastBuyPriceViable = false; const reason = buyAmount < EPS && isSpotMarket ? "现货可用报价资产不足,跳过买单" : "跳过买单:价差不足以构造maker价格"; this.tradeLog.push("info", reason); } } } if (!skipSellSide && canEnter) { const baseAvail = balancesForSpot?.baseAvailable ?? 0; const baseWallet = balancesForSpot?.baseWallet ?? baseAvail; const maxBase = Math.max(baseAvail, baseWallet); if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) { // 持仓低于最小卖单量,跳过卖单,等待累积 if (this.lastSellPriceViable) { this.lastSellPriceViable = false; this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单"); } } else { const desiredSellAmount = isSpotMarket && balancesForSpot ? balancesForSpot.baseAvailable : this.config.tradeAmount; const sellAmount = this.computeSpotOrderSize({ side: "SELL", desiredAmount: desiredSellAmount, price: askPrice != null ? Number(askPrice) : null, balances: balancesForSpot, }); if (askPrice != null && sellAmount >= EPS) { this.lastSellPriceViable = true; desired.push({ side: "SELL", price: askPrice, amount: sellAmount, reduceOnly: false }); } else if (this.lastSellPriceViable) { this.lastSellPriceViable = false; const reason = sellAmount < EPS && isSpotMarket ? "现货可用基础资产不足,跳过卖单" : "跳过卖单:价差不足以构造maker价格"; this.tradeLog.push("info", reason); } } } } else if (absPosition < EPS) { this.entryPricePendingLogged = false; if (!skipBuySide && canEnter) { if (isSpotMarket && !allowSpotBuy) { if (this.lastBuyPriceViable) { this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单"); this.lastBuyPriceViable = false; } } else { desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false }); } } if (!skipSellSide && canEnter) { if (isSpotMarket && minSell > 0 && this.minBaseAmount != null) { const baseAvail = balancesForSpot?.baseAvailable ?? 0; const baseWallet = balancesForSpot?.baseWallet ?? baseAvail; if (Math.max(baseAvail, baseWallet) + EPS < minSell) { this.lastSellPriceViable = false; this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单"); } } desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false }); } } else { const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY"; const closePrice = closeSide === "SELL" ? closeAskPrice : closeBidPrice; if (isSpotMarket && minSell > 0 && rawAbsPosition + EPS < minSell) { // 持仓未达最小卖出量,等待累积,不下单 this.lastSellPriceViable = false; this.lastBuyPriceViable = false; this.desiredOrders = []; this.sessionVolume.update(position, this.getReferencePrice()); this.emitUpdate(); return; } const closeQty = isSpotMarket && balancesForSpot ? this.computeSpotOrderSize({ side: "SELL", desiredAmount: rawAbsPosition, price: closePrice != null ? Number(closePrice) : null, balances: balancesForSpot, }) : rawAbsPosition; if (closePrice != null && closeQty >= EPS) { desired.push({ side: closeSide, price: closePrice, amount: closeQty, reduceOnly: false }); } } this.desiredOrders = desired; this.sessionVolume.update(position, this.getReferencePrice()); await this.syncOrders(desired); await this.checkRisk(position, Number(closeBidPrice), Number(closeAskPrice)); this.emitUpdate(); } catch (error) { if (isRateLimitError(error)) { hadRateLimit = true; this.rateLimit.registerRateLimit("offset-maker"); await this.enforceRateLimitStop(); this.tradeLog.push("warn", `OffsetMakerEngine 429: ${String(error)}`); } else { this.tradeLog.push("error", `偏移做市循环异常: ${String(error)}`); } this.emitUpdate(); } finally { this.rateLimit.onCycleComplete(hadRateLimit); this.processing = false; } } private async enforceRateLimitStop(): Promise { if (this.marketType === "spot") return; const position = this.getPositionSnapshot(); if (Math.abs(position.positionAmt) < EPS) return; await this.flushOrders(); const absPosition = Math.abs(position.positionAmt); const side: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY"; const { topBid, topAsk } = getTopPrices(this.depthSnapshot); const priceDecimals = this.getPriceDecimals(); const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null; const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null; try { await marketClose( this.exchange, this.config.symbol, this.openOrders, this.locks, this.timers, this.pending, side, absPosition, (type, detail) => this.tradeLog.push(type, detail), { markPrice: position.markPrice, expectedPrice: side === "SELL" ? (closeAskPrice != null ? Number(closeAskPrice) : null) : (closeBidPrice != null ? Number(closeBidPrice) : null), maxPct: this.config.maxCloseSlippagePct, }, { qtyStep: this.qtyStep } ); } catch (error) { if (isUnknownOrderError(error)) { this.tradeLog.push("order", "限频强制平仓时订单已不存在"); } else { this.tradeLog.push("error", `限频强制平仓失败: ${String(error)}`); } } } private async ensureStartupOrderReset(): Promise { if (this.initialOrderResetDone) return true; if (!this.initialOrderSnapshotReady) return false; if (!this.openOrders.length) { this.initialOrderResetDone = true; return true; } try { await this.exchange.cancelAllOrders({ symbol: this.config.symbol }); this.pendingCancelOrders.clear(); unlockOperating(this.locks, this.timers, this.pending, "LIMIT"); this.openOrders = []; this.emitUpdate(); this.tradeLog.push("order", "启动时清理历史挂单"); this.initialOrderResetDone = true; return true; } catch (error) { if (isUnknownOrderError(error)) { this.tradeLog.push("order", "历史挂单已消失,跳过启动清理"); this.initialOrderResetDone = true; this.openOrders = []; this.emitUpdate(); return true; } this.tradeLog.push("error", `启动撤单失败: ${String(error)}`); return false; } } private evaluateDepth(depth: Depth): { buySum: number; sellSum: number; skipBuySide: boolean; skipSellSide: boolean; imbalance: "balanced" | "buy_dominant" | "sell_dominant"; } { // Keep existing behavior: 10 levels, ratio threshold 3x return computeDepthStats(depth, 10, 3); } private async handleImbalanceExit( position: PositionSnapshot, buySum: number, sellSum: number ): Promise { if (this.marketType === "spot") return false; const absPosition = Math.abs(position.positionAmt); if (absPosition < EPS) return false; const longExitRequired = position.positionAmt > 0 && (buySum === 0 || buySum * 6 < sellSum); const shortExitRequired = position.positionAmt < 0 && (sellSum === 0 || sellSum * 6 < buySum); if (!longExitRequired && !shortExitRequired) return false; const side: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY"; const bid = Number(this.depthSnapshot?.bids?.[0]?.[0]); const ask = Number(this.depthSnapshot?.asks?.[0]?.[0]); const closeSidePrice = side === "SELL" ? bid : ask; this.tradeLog.push( "stop", `深度极端不平衡(${buySum.toFixed(4)} vs ${sellSum.toFixed(4)}), 市价平仓 ${side}` ); try { await this.flushOrders(); await marketClose( this.exchange, this.config.symbol, this.openOrders, this.locks, this.timers, this.pending, side, absPosition, (type, detail) => this.tradeLog.push(type, detail), { markPrice: position.markPrice, expectedPrice: Number(closeSidePrice) || null, maxPct: this.config.maxCloseSlippagePct, }, { qtyStep: this.qtyStep } ); } catch (error) { if (isUnknownOrderError(error)) { this.tradeLog.push("order", "深度不平衡平仓时订单已不存在"); } else { this.tradeLog.push("error", `深度不平衡平仓失败: ${String(error)}`); } } return true; } private async syncOrders(targets: DesiredOrder[]): Promise { const availableOrders = this.openOrders.filter((o) => !this.pendingCancelOrders.has(String(o.orderId))); const openOrders = availableOrders.filter((order) => isOrderActiveStatus(order.status)); // Coalesce reprices for entry orders: if within tick threshold or within dwell window, keep existing order const adjustedTargets: DesiredOrder[] = targets.map((t) => ({ ...t })); for (let i = 0; i < adjustedTargets.length; i++) { const t = adjustedTargets[i]; if (!t || t.reduceOnly) continue; // only suppress entry orders const existing = availableOrders.find((o) => o.side === t.side && o.reduceOnly !== true); if (!existing) continue; const newPrice = Number(t.price); const oldPrice = Number(existing.price); if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue; const ticksDiff = Math.abs(newPrice - oldPrice) / this.priceTick; const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0; const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs; if (ticksDiff < this.minRepriceTicks || withinDwell) { // Keep the existing resting order to avoid cancel/place churn adjustedTargets[i] = { side: t.side, price: String(existing.price), amount: t.amount, reduceOnly: false, }; } } const { toCancel, toPlace } = makeOrderPlan(openOrders, adjustedTargets); for (const order of toCancel) { if (this.pendingCancelOrders.has(String(order.orderId))) continue; this.pendingCancelOrders.add(String(order.orderId)); await safeCancelOrder( this.exchange, this.config.symbol, order, () => { this.tradeLog.push( "order", `撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}` ); // 保持与原逻辑一致:成功撤销不立即修改本地 openOrders,等待订单流重建 }, () => { this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略"); this.pendingCancelOrders.delete(String(order.orderId)); this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); }, (error) => { this.tradeLog.push("error", `撤销订单失败: ${String(error)}`); this.pendingCancelOrders.delete(String(order.orderId)); // 避免同一轮内重复操作同一张已出错的本地挂单,直接从本地缓存移除,等待下一次订单推送重建 this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); } ); } for (const target of toPlace) { if (!target) continue; if (target.amount < EPS) continue; if ( this.marketType === "spot" && this.minBaseAmount != null && target.side === "SELL" && target.amount + EPS < this.minBaseAmount ) { // Skip placing sells that would be bumped by venue minimums if (this.lastSellPriceViable) { this.lastSellPriceViable = false; this.tradeLog.push("info", "现货卖单低于最小成交量,跳过挂单等待累积"); } continue; } try { const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly; await placeOrder( this.exchange, this.config.symbol, this.openOrders, this.locks, this.timers, this.pending, target.side, target.price, // 已经是字符串价格 target.amount, (type, detail) => this.tradeLog.push(type, detail), reduceOnlyFlag, { markPrice: this.getPositionSnapshot().markPrice, maxPct: this.config.maxCloseSlippagePct, }, { priceTick: this.priceTick, qtyStep: this.qtyStep, } ); // Record last placed entry order timing and price if (!target.reduceOnly) { this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() }; } } catch (error) { if (isRateLimitError(error)) { throw error; } let dustClosed = false; try { dustClosed = await this.tryDustMarketClose(target, error); } catch (dustError) { if (isRateLimitError(dustError)) { throw dustError; } this.tradeLog.push("error", `小额市价平仓失败: ${String(dustError)}`); } if (dustClosed) continue; this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`); } } } private async checkRisk(position: PositionSnapshot, bidPrice: number, askPrice: number): Promise { // For spot: use balance-derived size; if loss exceeds threshold, market sell to exit. if (this.marketType === "spot") { const absPosition = Math.abs(position.positionAmt); if (absPosition < EPS) { this.lastSpotStopSkipped = false; return; } const minStopQty = Number.isFinite(this.minBaseAmount) ? this.minBaseAmount! : null; if (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) { if (!this.lastSpotStopSkipped) { this.tradeLog.push("info", "现货持仓低于最小平仓数量,跳过止损检查"); this.lastSpotStopSkipped = true; } return; } this.lastSpotStopSkipped = false; const pnl = computePositionPnl(position, bidPrice, askPrice); const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit); if (!triggerStop) return; this.tradeLog.push("stop", `现货止损,当前仓位=${absPosition.toFixed(6)} PnL=${pnl.toFixed(4)} USDT`); try { // 尽力撤销所有未完成挂单,避免锁定基础资产导致余额不足 await this.exchange.cancelAllOrders({ symbol: this.config.symbol }).catch(() => {}); await this.flushOrders(); await marketClose( this.exchange, this.config.symbol, this.openOrders, this.locks, this.timers, this.pending, "SELL", absPosition, (type, detail) => this.tradeLog.push(type, detail), { markPrice: position.markPrice, expectedPrice: bidPrice || null, maxPct: this.config.maxCloseSlippagePct, }, { qtyStep: this.qtyStep } ); } catch (error) { if (isRateLimitError(error)) throw error; if (isUnknownOrderError(error)) { this.tradeLog.push("order", "止损平仓时订单已不存在"); } else { this.tradeLog.push("error", `现货止损失败: ${String(error)}`); } } return; } const absPosition = Math.abs(position.positionAmt); if (absPosition < EPS) return; const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8; if (!hasEntryPrice) { if (!this.entryPricePendingLogged) { this.tradeLog.push("info", "做市持仓均价未同步,等待账户快照刷新后再执行止损判断"); this.entryPricePendingLogged = true; } return; } this.entryPricePendingLogged = false; const pnl = computePositionPnl(position, bidPrice, askPrice); const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit); if (triggerStop) { this.tradeLog.push( "stop", `触发止损,方向=${position.positionAmt > 0 ? "多" : "空"} 当前亏损=${pnl.toFixed(4)} USDT` ); try { await this.flushOrders(); await marketClose( this.exchange, this.config.symbol, this.openOrders, this.locks, this.timers, this.pending, position.positionAmt > 0 ? "SELL" : "BUY", absPosition, (type, detail) => this.tradeLog.push(type, detail), { markPrice: position.markPrice, expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null, maxPct: this.config.maxCloseSlippagePct, }, { qtyStep: this.qtyStep } ); } catch (error) { if (isUnknownOrderError(error)) { this.tradeLog.push("order", "止损平仓时订单已不存在"); } else { this.tradeLog.push("error", `止损平仓失败: ${String(error)}`); } } } } private async flushOrders(): Promise { if (!this.openOrders.length) return; for (const order of this.openOrders) { if (this.pendingCancelOrders.has(String(order.orderId))) continue; this.pendingCancelOrders.add(String(order.orderId)); await safeCancelOrder( this.exchange, this.config.symbol, order, () => { // 与原逻辑保持一致:成功撤销不记录日志且不修改本地 openOrders }, () => { this.tradeLog.push("order", "订单已不存在,撤销跳过"); this.pendingCancelOrders.delete(String(order.orderId)); this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); }, (error) => { this.tradeLog.push("error", `撤销订单失败: ${String(error)}`); this.pendingCancelOrders.delete(String(order.orderId)); // 与同步撤单路径保持一致,移除本地异常订单,等待订单流重建 this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); } ); } } 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.priceTick) > 1e-12) { this.priceTick = precision.priceTick; this.config.priceTick = precision.priceTick; updated = true; } } if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) { if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) { this.qtyStep = precision.qtyStep; updated = true; } } if (Number.isFinite(precision.minBaseAmount)) { this.minBaseAmount = precision.minBaseAmount!; } if (Number.isFinite(precision.minQuoteAmount)) { this.minQuoteAmount = precision.minQuoteAmount!; } if (updated) { this.tradeLog.push( "info", `已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}` ); } }) .catch((error) => { this.tradeLog.push("error", `同步精度失败: ${String(error)}`); this.precisionSync = null; setTimeout(() => this.syncPrecision(), 2000); }); } private getPriceDecimals(): number { const tick = Math.max(1e-9, this.priceTick); const raw = Math.log10(1 / tick); if (!Number.isFinite(raw)) return 0; return Math.max(0, Math.floor(raw + 1e-9)); } private emitUpdate(): void { try { const snapshot = this.buildSnapshot(); this.events.emit("update", snapshot, (error) => { this.tradeLog.push("error", `更新回调处理异常: ${String(error)}`); }); } catch (err) { this.tradeLog.push("error", `快照或更新分发异常: ${String(err)}`); } } private buildSnapshot(): OffsetMakerEngineSnapshot { const position = this.getPositionSnapshot(); const { topBid, topAsk } = getTopPrices(this.depthSnapshot); const spread = topBid != null && topAsk != null ? topAsk - topBid : null; const pnl = computePositionPnl(position, topBid, topAsk); return { ready: this.isReady(), symbol: this.config.symbol, topBid: topBid, topAsk: topAsk, spread, priceDecimals: this.getPriceDecimals(), position, pnl, accountUnrealized: this.accountUnrealized, sessionVolume: this.sessionVolume.value, openOrders: this.openOrders, desiredOrders: this.desiredOrders, tradeLog: this.tradeLog.all(), lastUpdated: Date.now(), feedStatus: { ...this.feedStatus }, buyDepthSum10: this.lastBuyDepthSum10, sellDepthSum10: this.lastSellDepthSum10, depthImbalance: this.lastImbalance, skipBuySide: this.lastSkipBuy, skipSellSide: this.lastSkipSell, marketType: this.marketType, baseAsset: this.baseAsset, quoteAsset: this.quoteAsset, spotBalances: this.marketType === "spot" ? this.getSpotBalances() : null, }; } private getReferencePrice(): number | null { return getMidOrLast(this.depthSnapshot, this.tickerSnapshot); } private isSpotKlineUp(): boolean { return this.spotKlineUp === true || this.isLiveCandleUp(); } private isLiveCandleUp(): boolean { if (!this.liveCandle) return false; return this.liveCandle.close > this.liveCandle.open; } private updateLiveCandle(): void { const price = this.getReferencePrice(); if (!Number.isFinite(price)) return; const now = Date.now(); const minuteStart = now - (now % 60000); if (!this.liveCandle || this.liveCandle.startMs !== minuteStart) { this.liveCandle = { startMs: minuteStart, open: price as number, close: price as number }; } else { this.liveCandle.close = price as number; } this.spotKlineUp = this.isLiveCandleUp(); } private getPositionSnapshot(): PositionSnapshot { const position = getPosition(this.accountSnapshot, this.config.symbol); if (this.marketType === "spot" && this.spotEntryPrice != null && Math.abs(position.positionAmt) > EPS) { return { ...position, entryPrice: this.spotEntryPrice }; } return position; } private getSpotBalances(snapshot: AccountSnapshot | null = this.accountSnapshot): { baseAvailable: number; quoteAvailable: number; baseWallet: number } | null { const assets = snapshot?.assets ?? []; if (!assets.length) return null; const parsed = parseSymbolParts(this.config.symbol); const baseSymbol = (this.baseAsset ?? snapshot?.baseAsset ?? parsed.base ?? "").toUpperCase(); const quoteSymbol = (this.quoteAsset ?? snapshot?.quoteAsset ?? parsed.quote ?? "").toUpperCase(); const baseId = snapshot?.baseAssetId ?? this.baseAssetId ?? null; const quoteId = snapshot?.quoteAssetId ?? this.quoteAssetId ?? null; const normalize = (asset?: string) => (asset ? asset.toUpperCase() : ""); const pickAvailable = (asset?: { availableBalance?: string; walletBalance: string }) => { const available = Number(asset?.availableBalance ?? asset?.walletBalance ?? 0); return Number.isFinite(available) ? available : 0; }; const pickWallet = (asset?: { walletBalance: string }) => { const wallet = Number(asset?.walletBalance ?? 0); return Number.isFinite(wallet) ? wallet : 0; }; const baseAssetEntry = assets.find( (asset) => (Number.isFinite(baseId) && Number(asset.assetId) === Number(baseId)) || normalize(asset.asset) === baseSymbol ); const quoteAssetEntry = assets.find( (asset) => (Number.isFinite(quoteId) && Number(asset.assetId) === Number(quoteId)) || normalize(asset.asset) === quoteSymbol ); return { baseAvailable: pickAvailable(baseAssetEntry), quoteAvailable: pickAvailable(quoteAssetEntry), baseWallet: pickWallet(baseAssetEntry), }; } private computeSpotOrderSize(params: { side: "BUY" | "SELL"; desiredAmount: number; price: number | null; balances: { baseAvailable: number; quoteAvailable: number; baseWallet?: number } | null; }): number { const desired = Number(params.desiredAmount); if (!Number.isFinite(desired) || desired <= 0) return 0; if (!params.balances) return desired; if (params.side === "SELL") { const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0); if (this.minBaseAmount != null && cap + EPS < this.minBaseAmount) { return 0; // below venue min trade size; skip sell until enough balance } return this.roundToStep(Math.max(0, Math.min(desired, cap))); } const price = Number(params.price); const quoteAvailable = Math.max(0, params.balances.quoteAvailable ?? 0); if (!Number.isFinite(price) || price <= 0) return desired; const maxByQuote = quoteAvailable / price; return this.roundToStep(Math.max(0, Math.min(desired, maxByQuote))); } private roundToStep(amount: number): number { const step = Math.max(1e-9, this.qtyStep); return Math.floor(amount / step) * step; } private ensureMakerPrice( side: "BUY" | "SELL", rawPrice: number, topBid: number | null, topAsk: number | null ): number | null { if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null; const tick = Math.max(this.priceTick, 1e-9); if (side === "BUY") { if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice; const maxPrice = Number(topAsk) - tick; if (!Number.isFinite(maxPrice) || maxPrice <= 0) return null; const adjusted = Math.min(rawPrice, maxPrice); return adjusted > 0 ? adjusted : null; } if (side === "SELL") { if (topBid == null || !Number.isFinite(topBid)) return rawPrice; const minPrice = Number(topBid) + tick; if (!Number.isFinite(minPrice) || minPrice <= 0) return null; const adjusted = Math.max(rawPrice, minPrice); return adjusted > 0 ? adjusted : null; } return rawPrice; } private isInvalidAmountError(error: unknown): boolean { const message = typeof error === "string" ? error : error instanceof Error ? error.message : JSON.stringify(error); if (!message) return false; if (message.includes("\"code\":21706")) return true; return message.toLowerCase().includes("invalid order base or quote amount"); } private async tryDustMarketClose(target: DesiredOrder, error: unknown): Promise { if (!target.reduceOnly) return false; if (!this.isInvalidAmountError(error)) return false; const position = this.getPositionSnapshot(); const absQty = Math.abs(target.amount); if (absQty < EPS) return false; const { topBid, topAsk } = getTopPrices(this.depthSnapshot); try { await marketClose( this.exchange, this.config.symbol, this.openOrders, this.locks, this.timers, this.pending, target.side, absQty, (type, detail) => this.tradeLog.push(type, detail), { markPrice: position.markPrice, expectedPrice: target.side === "SELL" ? (topBid != null ? Number(topBid) : null) : (topAsk != null ? Number(topAsk) : null), maxPct: this.config.maxCloseSlippagePct, }, { qtyStep: this.qtyStep } ); this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`); return true; } catch (closeError) { if (isRateLimitError(closeError)) { throw closeError; } this.tradeLog.push("error", `小额市价平仓失败: ${String(closeError)}`); return false; } } }