import crypto from "crypto"; import type { TradingConfig } from "../config"; import type { ExchangeAdapter } from "../exchanges/adapter"; import type { AccountSnapshot, Order, Ticker, Depth, Kline, } from "../exchanges/types"; import { calcStopLossPrice, calcTrailingActivationPrice, computeBollingerBandwidth, getPosition, getSMA, type PositionSnapshot, } from "../utils/strategy"; import { computePositionPnl } from "../utils/pnl"; import { getMidOrLast } from "../utils/price"; import { marketClose, placeMarketOrder, placeStopLossOrder, placeTrailingStopOrder, unlockOperating, } from "../core/order-coordinator"; import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator"; import { extractMessage, isUnknownOrderError } from "../utils/errors"; import { formatPriceToString } from "../utils/math"; import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; import { decryptCopyright } from "../utils/copyright"; import { isRateLimitError } from "../utils/errors"; import { RateLimitController } from "../core/lib/rate-limit"; import type { TrendLabel } from "../utils/format"; import { StrategyEventEmitter } from "./common/event-emitter"; import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer"; import { safeSubscribe, type LogHandler } from "./common/subscriptions"; import { SessionVolumeTracker } from "./common/session-volume"; import { t } from "../i18n"; export interface TrendEngineSnapshot { ready: boolean; symbol: string; lastPrice: number | null; sma30: number | null; bollingerBandwidth: number | null; trend: TrendLabel; position: PositionSnapshot; pnl: number; unrealized: number; totalProfit: number; totalTrades: number; sessionVolume: number; tradeLog: TradeLogEntry[]; openOrders: Order[]; depth: Depth | null; ticker: Ticker | null; lastUpdated: number | null; lastOpenSignal: OpenOrderPlan; } export interface OpenOrderPlan { side: "BUY" | "SELL" | null; price: number | null; } type TrendEngineEvent = "update"; type TrendEngineListener = (snapshot: TrendEngineSnapshot) => void; export class TrendEngine { private accountSnapshot: AccountSnapshot | null = null; private openOrders: Order[] = []; private depthSnapshot: Depth | null = null; private tickerSnapshot: Ticker | null = null; private klineSnapshot: Kline[] = []; private readonly locks: OrderLockMap = {}; private readonly timers: OrderTimerMap = {}; private readonly pending: OrderPendingMap = {}; private readonly tradeLog: ReturnType; private readonly events = new StrategyEventEmitter(); private readonly sessionVolume = new SessionVolumeTracker(); private timer: ReturnType | null = null; private processing = false; private lastPrice: number | null = null; private lastSma30: number | null = null; private lastBollingerBandwidth: number | null = null; private totalProfit = 0; private totalTrades = 0; private lastOpenPlan: OpenOrderPlan = { side: null, price: null }; private cancelAllRequested = false; private readonly pendingCancelOrders = new Set(); private readonly rateLimit: RateLimitController; private lastAccountPosition: PositionSnapshot = { positionAmt: 0, entryPrice: 0, unrealizedProfit: 0, markPrice: null, }; private pendingRealized: { pnl: number; timestamp: number } | null = null; private klineInsufficientLogged = false; private klineReadyLogged = false; // 控制入场频率:同一分钟内最多入场一次 private lastEntryMinute: number | null = null; // 止损后冷却:止损发生后的 60s 内忽略 SMA 入场信号 private lastStopLossAt: number | null = null; private lastBollingerBlockLogged = 0; private ordersSnapshotReady = false; private startupLogged = false; private entryPricePendingLogged = false; // 记录最近一次止损下单尝试,用于抑制在无订单流识别时的重复挂单 private lastStopAttempt: { side: "BUY" | "SELL" | null; price: number | null; at: number } = { side: null, price: null, at: 0, }; private readonly copyrightFingerprint = crypto .createHash("sha256") .update(decryptCopyright()) .digest("hex"); private readonly listeners = new Map>(); private readonly precision: PrecisionSyncer; constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) { this.tradeLog = createTradeLog(this.config.maxLogEntries); this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) => this.tradeLog.push(type, detail) ); this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) => this.tradeLog.push(type, detail) ); this.precision.start(); this.bootstrap(); } /** Bundles the fixed order-routing state; rebuilt lazily on first use. */ private get orderContext(): OrderContext { return (this.orderContextCache ??= { adapter: this.exchange, symbol: this.config.symbol, locks: this.locks, timers: this.timers, pendings: this.pending, log: (type, detail) => this.tradeLog.push(type, detail), }); } private orderContextCache: OrderContext | null = null; start(): void { if (this.timer) return; this.timer = setInterval(() => { void this.tick(); }, this.config.pollIntervalMs); } stop(): void { if (this.timer) { clearInterval(this.timer); this.timer = null; } this.precision.stop(); } on(event: TrendEngineEvent, handler: TrendEngineListener): void { this.events.on(event, handler); } off(event: TrendEngineEvent, handler: TrendEngineListener): void { this.events.off(event, handler); } getSnapshot(): TrendEngineSnapshot { 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; const position = getPosition(snapshot, this.config.symbol); const reference = this.getReferencePrice(); this.sessionVolume.update(position, reference); this.trackPositionLifecycle(position, reference); this.emitUpdate(); }, log, { subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }), processFail: (error) => t("log.process.accountError", { error: extractMessage(error) }), } ); safeSubscribe( this.exchange.watchOrders.bind(this.exchange), (orders) => { this.synchronizeLocks(orders); const isActive = (status: string | undefined) => { if (!status) return true; const normalized = status.toLowerCase(); return normalized !== "filled" && normalized !== "canceled" && normalized !== "cancelled"; }; this.openOrders = Array.isArray(orders) ? orders.filter( (order) => order.type !== "MARKET" && order.symbol === this.config.symbol && isActive(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); } } if (this.openOrders.length === 0 || this.pendingCancelOrders.size === 0) { this.cancelAllRequested = false; } this.ordersSnapshotReady = true; this.emitUpdate(); }, log, { subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }), processFail: (error) => t("log.process.orderError", { error: extractMessage(error) }), } ); safeSubscribe( this.exchange.watchDepth.bind(this.exchange, this.config.symbol), (depth) => { this.depthSnapshot = depth; this.emitUpdate(); }, log, { subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }), processFail: (error) => t("log.process.depthError", { error: extractMessage(error) }), } ); safeSubscribe( this.exchange.watchTicker.bind(this.exchange, this.config.symbol), (ticker) => { this.tickerSnapshot = ticker; this.emitUpdate(); }, log, { subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }), processFail: (error) => t("log.process.tickerError", { error: extractMessage(error) }), } ); safeSubscribe( this.exchange.watchKlines.bind(this.exchange, this.config.symbol, this.config.klineInterval), (klines) => { this.klineSnapshot = Array.isArray(klines) ? klines : []; const latestSma = getSMA(this.klineSnapshot, 30); this.lastSma30 = latestSma; this.logKlineSnapshot(); this.emitUpdate(); }, log, { subscribeFail: (error) => t("log.kline.subscribeFail", { error: String(error) }), processFail: (error) => t("log.kline.processError", { error: extractMessage(error) }), } ); } private synchronizeLocks(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")) { unlockOperating(this.locks, this.timers, this.pending, type); } }); } private isReady(): boolean { const minKlines = Math.max(30, this.config.bollingerLength); return Boolean( this.accountSnapshot && this.tickerSnapshot && this.depthSnapshot && this.klineSnapshot.length >= minKlines ); } private logKlineSnapshot(): void { const minKlines = Math.max(30, this.config.bollingerLength); const count = this.klineSnapshot.length; if (count < minKlines) { if (!this.klineInsufficientLogged) { const closes = this.klineSnapshot.slice(-5).map((k) => Number(k.close).toFixed(2)); this.tradeLog.push( "info", t("log.trend.klineInsufficient", { count, min: minKlines, recentCount: closes.length, recent: closes.join(", "), }) ); this.klineInsufficientLogged = true; } return; } if (!this.klineReadyLogged) { const closes = this.klineSnapshot.slice(-5).map((k) => Number(k.close).toFixed(2)); this.tradeLog.push( "info", t("log.trend.klineReady", { count, recent: closes.join(", ") }) ); this.klineReadyLogged = true; } this.klineInsufficientLogged = false; } 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.ordersSnapshotReady) { this.emitUpdate(); return; } if (!this.isReady()) { this.emitUpdate(); return; } this.logStartupState(); const sma30 = getSMA(this.klineSnapshot, 30); if (sma30 == null) { return; } const bollingerBandwidth = computeBollingerBandwidth( this.klineSnapshot, this.config.bollingerLength, this.config.bollingerStdMultiplier ); this.lastBollingerBandwidth = bollingerBandwidth; const ticker = this.tickerSnapshot!; const price = Number(ticker.lastPrice); const position = getPosition(this.accountSnapshot, this.config.symbol); if (Math.abs(position.positionAmt) < 1e-5) { if (!this.rateLimit.shouldBlockEntries()) { await this.handleOpenPosition(price, sma30, bollingerBandwidth); } } else { const result = await this.handlePositionManagement(position, price); if (result.closed) { this.pendingRealized = { pnl: result.pnl, timestamp: Date.now() }; } } this.sessionVolume.update(position, price); this.trackPositionLifecycle(position, price); this.lastSma30 = sma30; this.lastPrice = price; this.emitUpdate(); } catch (error) { if (isRateLimitError(error)) { hadRateLimit = true; this.rateLimit.registerRateLimit("trend"); await this.enforceRateLimitStop(); this.tradeLog.push("warn", t("log.trend.rateLimit429", { error: String(error) })); } else { this.tradeLog.push("error", t("log.trend.loopError", { error: String(error) })); } this.emitUpdate(); } finally { try { this.rateLimit.onCycleComplete(hadRateLimit); } catch (rateLimitError) { this.tradeLog.push("error", t("log.trend.rateLimitUpdateError", { error: String(rateLimitError) })); } finally { this.processing = false; } } } private async enforceRateLimitStop(): Promise { const position = getPosition(this.accountSnapshot, this.config.symbol); if (Math.abs(position.positionAmt) < 1e-5) return; const price = this.getReferencePrice(); if (!Number.isFinite(price) || price == null) return; const result = await this.handlePositionManagement(position, Number(price)); if (result.closed) { this.pendingRealized = { pnl: result.pnl, timestamp: Date.now() }; } } private logStartupState(): void { if (this.startupLogged) return; const position = getPosition(this.accountSnapshot, this.config.symbol); const hasPosition = Math.abs(position.positionAmt) > 1e-5; if (hasPosition) { this.tradeLog.push( "info", t("log.trend.detectPosition", { direction: position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"), amount: Math.abs(position.positionAmt).toFixed(4), price: position.entryPrice.toFixed(2), }) ); } if (this.openOrders.length > 0) { this.tradeLog.push("info", t("log.trend.detectOrders", { count: this.openOrders.length })); } this.startupLogged = true; } private async handleOpenPosition( currentPrice: number, currentSma: number, currentBandwidth: number | null ): Promise { this.entryPricePendingLogged = false; const now = Date.now(); const currentMinute = Math.floor(now / 60_000); // 止损后的冷却期:60s 内不允许基于 SMA 穿越再次入场 if (this.lastStopLossAt != null && now - this.lastStopLossAt < 60_000) { const remaining = Math.max(0, 60_000 - (now - this.lastStopLossAt)); this.tradeLog.push("info", t("log.trend.stopCooldown", { seconds: (remaining / 1000).toFixed(0) })); return; } // 同一分钟只允许一次入场 if (this.lastEntryMinute != null && this.lastEntryMinute === currentMinute) { this.tradeLog.push("info", t("log.trend.alreadyEntered")); return; } if ( Number.isFinite(currentBandwidth) && this.config.minBollingerBandwidth > 0 && Number(currentBandwidth) < this.config.minBollingerBandwidth ) { if (now - this.lastBollingerBlockLogged > 15_000) { this.tradeLog.push( "info", t("log.trend.bandwidthBlocked", { bandwidth: Number(currentBandwidth).toFixed(4), minBandwidth: this.config.minBollingerBandwidth, }) ); this.lastBollingerBlockLogged = now; } return; } if (this.lastPrice == null) { this.lastPrice = currentPrice; return; } if (this.openOrders.length > 0 && !this.cancelAllRequested) { try { await this.exchange.cancelAllOrders({ symbol: this.config.symbol }); this.cancelAllRequested = true; // 清空本地挂单与撤单队列,避免在下一轮中基于过期快照继续操作 this.pendingCancelOrders.clear(); this.openOrders = []; } catch (err) { if (isUnknownOrderError(err)) { this.tradeLog.push("order", t("log.trend.cancelMissing")); this.cancelAllRequested = true; // 与成功撤单路径保持一致,立即清空本地缓存,等待订单流推送重建 this.pendingCancelOrders.clear(); this.openOrders = []; } else { this.tradeLog.push("error", t("log.trend.cancelFail", { error: String(err) })); this.cancelAllRequested = false; } } } if (this.lastPrice > currentSma && currentPrice < currentSma) { await this.submitMarketOrder("SELL", currentPrice, t("log.trend.crossDown")); this.lastEntryMinute = currentMinute; } else if (this.lastPrice < currentSma && currentPrice > currentSma) { await this.submitMarketOrder("BUY", currentPrice, t("log.trend.crossUp")); this.lastEntryMinute = currentMinute; } } private async submitMarketOrder(side: "BUY" | "SELL", price: number, reason: string): Promise { try { await placeMarketOrder(this.orderContext, { openOrders: this.openOrders, side: side, amount: this.config.tradeAmount, reduceOnly: false, guard: { markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice, expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null, maxPct: this.config.maxCloseSlippagePct, }, qtyStep: this.config.qtyStep }); this.tradeLog.push("open", `${reason}: ${side} @ ${price}`); this.lastOpenPlan = { side, price }; } catch (err) { this.tradeLog.push("error", t("log.trend.marketOrderFail", { error: String(err) })); } } private async handlePositionManagement( position: PositionSnapshot, price: number ): Promise<{ closed: boolean; pnl: number }> { const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8; if (!hasEntryPrice) { if (!this.entryPricePendingLogged) { this.tradeLog.push("info", t("log.trend.entryPricePending")); this.entryPricePendingLogged = true; } return { closed: false, pnl: position.unrealizedProfit }; } this.entryPricePendingLogged = false; const direction = position.positionAmt > 0 ? "long" : "short"; const qtyAbs = Math.abs(position.positionAmt); const depthBid = Number(this.depthSnapshot?.bids?.[0]?.[0]); const depthAsk = Number(this.depthSnapshot?.asks?.[0]?.[0]); const closeSidePriceRaw = direction === "long" ? depthBid : depthAsk; const effectiveClosePrice = Number.isFinite(closeSidePriceRaw) ? closeSidePriceRaw : Number.isFinite(price) ? price : position.entryPrice; const pnl = qtyAbs > 0 ? (direction === "long" ? effectiveClosePrice - position.entryPrice : position.entryPrice - effectiveClosePrice) * qtyAbs : 0; const unrealized = Number.isFinite(position.unrealizedProfit) ? position.unrealizedProfit : null; const stopSide = direction === "long" ? "SELL" : "BUY"; const stopPrice = calcStopLossPrice( position.entryPrice, Math.abs(position.positionAmt), direction, this.config.lossLimit ); const activationPrice = calcTrailingActivationPrice( position.entryPrice, Math.abs(position.positionAmt), direction, this.config.trailingProfit ); // 对于部分交易所(如 Lighter),触发类订单在订单流中可能显示为 LIMIT,但会带有 stopPrice。 // 因此将带有有效 stopPrice 的同向订单也视为当前止损单。 const currentStop = this.openOrders.find((o) => { const hasStopPrice = Number.isFinite(Number(o.stopPrice)) && Number(o.stopPrice) > 0; return o.side === stopSide && (o.type === "STOP_MARKET" || hasStopPrice); }); const currentTrailing = this.openOrders.find( (o) => o.type === "TRAILING_STOP_MARKET" && o.side === stopSide ); // 步进式锁盈移动:在动态止盈生效前,盈利每增加一个 profitLockOffsetUsd 就上移/下移一次止损 { const tick = Math.max(1e-9, this.config.priceTick); const qtyAbs = Math.abs(position.positionAmt); const stepUsd = Math.max(0, this.config.profitLockOffsetUsd); const triggerUsd = Math.max(0, this.config.profitLockTriggerUsd); const trailingActivateFromOrderRaw = currentTrailing?.activatePrice ?? (currentTrailing as any)?.activationPrice; const trailingActivateFromOrder = Number(trailingActivateFromOrderRaw); const trailingActivate = Number.isFinite(trailingActivateFromOrder) ? trailingActivateFromOrder : activationPrice; // 判断动态止盈是否已生效:多头 price >= activate;空头 price <= activate const trailingActivated = direction === "long" ? Number.isFinite(trailingActivate) && price >= trailingActivate - tick : Number.isFinite(trailingActivate) && price <= trailingActivate + tick; // 仅在动态止盈未生效时执行步进移动 if (!trailingActivated && qtyAbs > 0 && stepUsd > 0) { const basisProfit = Number.isFinite(unrealized ?? pnl) ? Math.max(pnl, unrealized ?? pnl) : pnl; if (basisProfit >= triggerUsd) { const over = basisProfit - triggerUsd; const steps = 1 + Math.floor(over / stepUsd); const stepPx = stepUsd / qtyAbs; const rawTarget = direction === "long" ? position.entryPrice + steps * stepPx : position.entryPrice - steps * stepPx; let targetStop = Number(formatPriceToString(rawTarget, Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick))))); // 不允许下一次移动超过动态止盈订单的激活价 if (Number.isFinite(trailingActivate)) { if (stopSide === "SELL" && targetStop >= trailingActivate - tick) { // 达到或超过激活价,停止移动 targetStop = Math.min(targetStop, trailingActivate - tick); // 若已经无法进一步改善,则不再尝试 const existingRaw = Number(currentStop?.stopPrice); const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN; const canImprove = !Number.isFinite(existingPrice) || (stopSide === "SELL" && targetStop >= existingPrice + tick); if (!canImprove) { // 直接跳过 // no-op } else if (currentStop) { await this.tryReplaceStop(stopSide, currentStop, targetStop, price); } else { await this.tryPlaceStopLoss(stopSide, targetStop, price); } } else if (stopSide === "BUY" && targetStop <= trailingActivate + tick) { targetStop = Math.max(targetStop, trailingActivate + tick); const existingRaw = Number(currentStop?.stopPrice); const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN; const canImprove = !Number.isFinite(existingPrice) || (stopSide === "BUY" && targetStop <= existingPrice - tick); if (!canImprove) { // no-op } else if (currentStop) { await this.tryReplaceStop(stopSide, currentStop, targetStop, price); } else { await this.tryPlaceStopLoss(stopSide, targetStop, price); } } else { // 正常范围内,且必须与当前价方向不冲突 const validForSide = (stopSide === "SELL" && targetStop <= price - tick) || (stopSide === "BUY" && targetStop >= price + tick); if (validForSide) { if (!currentStop) { await this.tryPlaceStopLoss(stopSide, targetStop, price); } else { const existingRaw = Number(currentStop.stopPrice); const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN; const improves = !Number.isFinite(existingPrice) || (stopSide === "SELL" && targetStop >= existingPrice + tick) || (stopSide === "BUY" && targetStop <= existingPrice - tick); if (improves) { await this.tryReplaceStop(stopSide, currentStop, targetStop, price); } } } } } else { // 无法取得动态止盈激活价时,仅按普通步进逻辑 const validForSide = (stopSide === "SELL" && targetStop <= price - tick) || (stopSide === "BUY" && targetStop >= price + tick); if (validForSide) { if (!currentStop) { await this.tryPlaceStopLoss(stopSide, targetStop, price); } else { const existingRaw = Number(currentStop.stopPrice); const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN; const improves = !Number.isFinite(existingPrice) || (stopSide === "SELL" && targetStop >= existingPrice + tick) || (stopSide === "BUY" && targetStop <= existingPrice - tick); if (improves) { await this.tryReplaceStop(stopSide, currentStop, targetStop, price); } } } } } } } if (!currentStop) { await this.tryPlaceStopLoss( stopSide, Number( formatPriceToString( stopPrice, Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick))) ) ), price ); } if (!currentTrailing && this.exchange.supportsTrailingStops()) { await this.tryPlaceTrailingStop( stopSide, Number(formatPriceToString(activationPrice, Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick))))), Math.abs(position.positionAmt) ); } const derivedLoss = pnl < -this.config.lossLimit; const snapshotLoss = derivedLoss; if (derivedLoss || snapshotLoss) { const result = { closed: false, pnl }; try { if (this.openOrders.length > 0) { const orderIdList = this.openOrders.map((order) => order.orderId); const orderIdSet = new Set(orderIdList.map(String)); try { await this.exchange.cancelOrders({ symbol: this.config.symbol, orderIdList }); orderIdSet.forEach((id) => this.pendingCancelOrders.add(id)); } catch (err) { if (isUnknownOrderError(err)) { this.tradeLog.push("order", t("log.trend.stopPreCancelMissing")); // 清理本地缓存,避免重复对同一订单执行撤单 for (const id of orderIdSet) { this.pendingCancelOrders.delete(id); } this.openOrders = this.openOrders.filter((o) => !orderIdSet.has(String(o.orderId))); } else { throw err; } } } // 价格操纵保护:仅当平仓方向价格与标记价格偏离在阈值内才执行市价平仓 const mark = getPosition(this.accountSnapshot, this.config.symbol).markPrice; const limitPct = this.config.maxCloseSlippagePct; const sideIsSell = direction === "long"; const depthBid = Number(this.depthSnapshot?.bids?.[0]?.[0]); const depthAsk = Number(this.depthSnapshot?.asks?.[0]?.[0]); const closeSidePrice = sideIsSell ? depthBid : depthAsk; if (mark != null && Number.isFinite(mark) && mark > 0 && Number.isFinite(closeSidePrice)) { const pctDiff = Math.abs(closeSidePrice - mark) / mark; if (pctDiff > limitPct) { this.tradeLog.push( "info", t("log.trend.marketCloseGuard", { closePx: Number(closeSidePrice).toFixed(2), mark: mark.toFixed(2), pctDiff: (pctDiff * 100).toFixed(2), limitPct: (limitPct * 100).toFixed(2), }) ); return { closed: false, pnl }; } } await marketClose(this.orderContext, { openOrders: this.openOrders, side: direction === "long" ? "SELL" : "BUY", quantity: Math.abs(position.positionAmt), guard: { markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice, expectedPrice: Number( direction === "long" ? this.depthSnapshot?.bids?.[0]?.[0] : this.depthSnapshot?.asks?.[0]?.[0] ) || null, maxPct: this.config.maxCloseSlippagePct, }, qtyStep: this.config.qtyStep }); result.closed = true; this.tradeLog.push("close", t("log.trend.stopClose", { side: direction === "long" ? "SELL" : "BUY" })); // 记录止损时间以便短期内抑制再次入场 this.lastStopLossAt = Date.now(); } catch (err) { if (isUnknownOrderError(err)) { this.tradeLog.push("order", t("log.trend.targetStopMissing")); } else { this.tradeLog.push("error", t("log.trend.stopCloseFail", { error: String(err) })); } return result; } return result; } return { closed: false, pnl }; } private async tryPlaceStopLoss( side: "BUY" | "SELL", stopPrice: number, lastPrice: number ): Promise { // 短期去抖:在订单流无法正确识别止损单时,避免在极短时间内重复提交同价同向止损 const tick = Math.max(1e-9, this.config.priceTick); const now = Date.now(); if ( this.lastStopAttempt.side === side && this.lastStopAttempt.price != null && Math.abs(stopPrice - Number(this.lastStopAttempt.price)) < tick && now - this.lastStopAttempt.at < 5000 ) { // 5 秒内同向同价重复尝试,直接跳过 return; } try { const position = getPosition(this.accountSnapshot, this.config.symbol); const quantity = Math.abs(position.positionAmt); const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12; if (quantity <= minQty) { return; } await placeStopLossOrder(this.orderContext, { openOrders: this.openOrders, side: side, stopPrice: stopPrice, quantity: quantity, lastPrice: lastPrice, guard: { markPrice: position.markPrice, maxPct: this.config.maxCloseSlippagePct, }, priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }); this.lastStopAttempt = { side, price: stopPrice, at: Date.now() }; } catch (err) { this.tradeLog.push("error", t("log.trend.placeStopFail", { error: String(err) })); // 记录尝试以避免在错误被抛回时立即再次重复尝试 this.lastStopAttempt = { side, price: stopPrice, at: Date.now() }; } } private async tryReplaceStop( side: "BUY" | "SELL", currentOrder: Order, nextStopPrice: number, lastPrice: number ): Promise { // 预校验:SELL 止损价必须低于当前价;BUY 止损价必须高于当前价 const invalidForSide = (side === "SELL" && nextStopPrice >= lastPrice) || (side === "BUY" && nextStopPrice <= lastPrice); if (invalidForSide) { // 目标止损价与当前价冲突时跳过移动,避免反复撤单/重下导致的循环 return; } const existingStopPrice = Number(currentOrder.stopPrice); try { await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: currentOrder.orderId }); } catch (err) { if (isUnknownOrderError(err)) { this.tradeLog.push("order", t("log.trend.stopMissingSkip")); // 订单已不存在,移除本地记录,防止后续重复匹配 this.openOrders = this.openOrders.filter((o) => o.orderId !== currentOrder.orderId); } else { this.tradeLog.push("error", t("log.trend.cancelStopFail", { error: String(err) })); } } // 仅在成功创建新止损单后记录“移动止损”日志 try { const position = getPosition(this.accountSnapshot, this.config.symbol); const quantity = Math.abs(position.positionAmt); const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12; if (quantity <= minQty) { return; } const order = await placeStopLossOrder(this.orderContext, { openOrders: this.openOrders, side: side, stopPrice: nextStopPrice, quantity: quantity, lastPrice: lastPrice, guard: { markPrice: position.markPrice, maxPct: this.config.maxCloseSlippagePct, }, priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }); if (order) { this.tradeLog.push( "stop", t("log.trend.moveStop", { price: formatPriceToString( nextStopPrice, Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick))) ), }) ); } } catch (err) { this.tradeLog.push("error", t("log.trend.moveStopFail", { error: String(err) })); // 回滚策略:尝试用原价恢复止损,以避免出现短时间内无止损保护 try { const position = getPosition(this.accountSnapshot, this.config.symbol); const quantity = Math.abs(position.positionAmt); const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12; if (quantity <= minQty) { return; } const restoreInvalid = (side === "SELL" && existingStopPrice >= lastPrice) || (side === "BUY" && existingStopPrice <= lastPrice); if (!restoreInvalid) { const restored = await placeStopLossOrder(this.orderContext, { openOrders: this.openOrders, side: side, stopPrice: existingStopPrice, quantity: quantity, lastPrice: lastPrice, guard: { markPrice: position.markPrice, maxPct: this.config.maxCloseSlippagePct, }, priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }); if (restored) { this.tradeLog.push( "order", t("log.trend.restoreStop", { price: formatPriceToString( existingStopPrice, Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick))) ), }) ); } } } catch (recoverErr) { this.tradeLog.push("error", t("log.trend.restoreStopFail", { error: String(recoverErr) })); } } } private async tryPlaceTrailingStop( side: "BUY" | "SELL", activationPrice: number, quantity: number ): Promise { if (!this.exchange.supportsTrailingStops()) { return; } try { await placeTrailingStopOrder(this.orderContext, { openOrders: this.openOrders, side: side, activationPrice: activationPrice, quantity: quantity, callbackRate: this.config.trailingCallbackRate, guard: { markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice, maxPct: this.config.maxCloseSlippagePct, }, priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }); } catch (err) { this.tradeLog.push("error", t("log.trend.trailingFail", { error: String(err) })); } } private emitUpdate(): void { try { const snapshot = this.buildSnapshot(); this.events.emit("update", snapshot, (error) => { this.tradeLog.push("error", t("log.trend.updateHandlerError", { error: String(error) })); }); } catch (err) { this.tradeLog.push("error", t("log.trend.snapshotDispatchError", { error: String(err) })); } } private buildSnapshot(): TrendEngineSnapshot { const position = getPosition(this.accountSnapshot, this.config.symbol); const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null; const sma30 = this.lastSma30; const trend: TrendLabel = price == null || sma30 == null ? "none" : price > sma30 ? "long" : price < sma30 ? "short" : "none"; const pnl = price != null ? computePositionPnl(position, price, price) : 0; return { ready: this.isReady(), symbol: this.config.symbol, lastPrice: price, sma30, bollingerBandwidth: this.lastBollingerBandwidth, trend, position, pnl, unrealized: position.unrealizedProfit, totalProfit: this.totalProfit, totalTrades: this.totalTrades, sessionVolume: this.sessionVolume.value, tradeLog: this.tradeLog.all(), openOrders: this.openOrders, depth: this.depthSnapshot, ticker: this.tickerSnapshot, lastUpdated: Date.now(), lastOpenSignal: this.lastOpenPlan, }; } private getReferencePrice(): number | null { return getMidOrLast(this.depthSnapshot, this.tickerSnapshot) ?? (this.lastPrice != null && Number.isFinite(this.lastPrice) ? this.lastPrice : null); } private trackPositionLifecycle(position: PositionSnapshot, referencePrice: number | null): void { const prev = this.lastAccountPosition; const prevExposure = Math.abs(prev.positionAmt) > 1e-5; const currentExposure = Math.abs(position.positionAmt) > 1e-5; const signChanged = prevExposure && currentExposure && Math.sign(prev.positionAmt) !== Math.sign(position.positionAmt); if (prevExposure && (!currentExposure || signChanged)) { let realized: number | null = this.pendingRealized?.pnl ?? null; if (!Number.isFinite(realized)) { realized = this.estimateRealizedPnl(prev, referencePrice); } if (Number.isFinite(realized)) { this.totalTrades += 1; this.totalProfit += realized ?? 0; } this.pendingRealized = null; } if (!prevExposure && currentExposure) { this.pendingRealized = null; } this.lastAccountPosition = { positionAmt: position.positionAmt, entryPrice: position.entryPrice, unrealizedProfit: position.unrealizedProfit, markPrice: position.markPrice, }; } private estimateRealizedPnl(position: PositionSnapshot, referencePrice: number | null): number { const fallbackPrice = referencePrice ?? this.getReferencePrice() ?? (this.lastPrice != null && Number.isFinite(this.lastPrice) ? this.lastPrice : position.entryPrice); if (!Number.isFinite(fallbackPrice)) { return 0; } return computePositionPnl(position, fallbackPrice, fallbackPrice); } }