import type { MakerConfig } from "../config"; import type { ExchangeAdapter } from "../exchanges/adapter"; import type { AccountSnapshot, Depth, Order, Ticker, } from "../exchanges/types"; import { formatPriceToString } from "../utils/math"; import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; import { extractMessage, isInsufficientBalanceError, isUnknownOrderError, isRateLimitError } from "../utils/errors"; import { isOrderActiveStatus } from "../utils/order-status"; import { getPosition } from "../utils/strategy"; import type { PositionSnapshot } from "../utils/strategy"; 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 { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator"; 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"; import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer"; import { t } from "../i18n"; interface DesiredOrder { side: "BUY" | "SELL"; price: string; // 改为字符串价格 amount: number; reduceOnly: boolean; } export interface MakerEngineSnapshot { ready: boolean; symbol: string; topBid: number | null; topAsk: number | null; spread: number | null; priceDecimals: number; position: PositionSnapshot; pnl: number; accountUnrealized: number; sessionVolume: number; openOrders: Order[]; desiredOrders: DesiredOrder[]; tradeLog: TradeLogEntry[]; lastUpdated: number | null; feedStatus: { account: boolean; orders: boolean; depth: boolean; ticker: boolean; }; } type MakerEvent = "update"; type MakerListener = (snapshot: MakerEngineSnapshot) => void; const EPS = 1e-5; const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000; /** Quantity step assumed until the exchange reports its own. */ const DEFAULT_QTY_STEP = 0.001; export class MakerEngine { private accountSnapshot: AccountSnapshot | null = null; private depthSnapshot: Depth | null = null; private tickerSnapshot: Ticker | 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 readonly precision: PrecisionSyncer; private timer: ReturnType | null = null; private processing = false; private desiredOrders: DesiredOrder[] = []; private accountUnrealized = 0; private initialOrderSnapshotReady = false; private initialOrderResetDone = false; private entryPricePendingLogged = false; private readinessLogged = { account: false, depth: false, ticker: false, orders: false, }; private feedArrived = { account: false, depth: false, ticker: false, orders: false, }; private feedStatus = { account: false, depth: false, ticker: false, orders: false, }; private insufficientBalanceCooldownUntil = 0; private insufficientBalanceNotified = false; private lastInsufficientMessage: string | null = null; private lastDesiredSummary: string | null = null; private readonly rateLimit: RateLimitController; 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.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (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.refreshIntervalMs); } stop(): void { if (this.timer) { clearInterval(this.timer); this.timer = null; } this.precision.stop(); } on(event: MakerEvent, handler: MakerListener): void { this.events.on(event, handler); } off(event: MakerEvent, handler: MakerListener): void { this.events.off(event, handler); } getSnapshot(): MakerEngineSnapshot { 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 totalUnrealized = Number(snapshot.totalUnrealizedProfit ?? "0"); if (Number.isFinite(totalUnrealized)) { this.accountUnrealized = totalUnrealized; } const position = getPosition(snapshot, this.config.symbol); this.sessionVolume.update(position, this.getReferencePrice()); if (!this.feedArrived.account) { this.tradeLog.push("info", t("log.account.snapshotSynced")); this.feedArrived.account = true; } this.feedStatus.account = true; this.emitUpdate(); }, log, { subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }), processFail: (error) => t("log.process.accountError", { error: String(error) }), } ); safeSubscribe( this.exchange.watchOrders.bind(this.exchange), (orders) => { this.syncLocksWithOrders(orders); 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; if (!this.feedArrived.orders) { this.tradeLog.push("info", t("log.order.snapshotReturned")); this.feedArrived.orders = true; } this.feedStatus.orders = true; this.emitUpdate(); }, log, { subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }), processFail: (error) => t("log.process.orderError", { error: String(error) }), } ); safeSubscribe( this.exchange.watchDepth.bind(this.exchange, this.config.symbol), (depth) => { this.depthSnapshot = depth; if (!this.feedArrived.depth) { this.tradeLog.push("info", t("log.depth.ready")); this.feedArrived.depth = true; } this.feedStatus.depth = true; this.emitUpdate(); }, log, { subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }), processFail: (error) => t("log.process.depthError", { error: String(error) }), } ); safeSubscribe( this.exchange.watchTicker.bind(this.exchange, this.config.symbol), (ticker) => { this.tickerSnapshot = ticker; if (!this.feedArrived.ticker) { this.tradeLog.push("info", t("log.ticker.ready")); this.feedArrived.ticker = true; } this.feedStatus.ticker = true; this.emitUpdate(); }, log, { subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }), processFail: (error) => t("log.process.tickerError", { error: String(error) }), } ); // Maker strategy does not require realtime klines. } 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.feedStatus.account && this.feedStatus.depth && this.feedStatus.ticker && this.feedStatus.orders ); } 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.logReadinessBlockers(); this.emitUpdate(); return; } this.resetReadinessFlags(); if (!(await this.ensureStartupOrderReset())) { this.emitUpdate(); return; } const depth = this.depthSnapshot!; const { topBid, topAsk } = getTopPrices(depth); if (topBid == null || topAsk == null) { this.emitUpdate(); return; } // 直接使用orderbook价格,格式化为字符串避免精度问题 const priceDecimals = this.getPriceDecimals(); // 平仓价格始终使用买1/卖1 const closeBidPrice = formatPriceToString(topBid, priceDecimals); const closeAskPrice = formatPriceToString(topAsk, priceDecimals); // 开仓价格根据 entryDepthLevel 使用指定档位 const entryLevel = this.config.entryDepthLevel ?? 1; const { bidAtLevel: entryBid, askAtLevel: entryAsk } = getPricesAtLevel(depth, entryLevel); const entryBidBase = entryBid ?? topBid; const entryAskBase = entryAsk ?? topAsk; const bidPrice = formatPriceToString(entryBidBase - this.config.bidOffset, priceDecimals); const askPrice = formatPriceToString(entryAskBase + this.config.askOffset, priceDecimals); const position = getPosition(this.accountSnapshot, this.config.symbol); const absPosition = Math.abs(position.positionAmt); const desired: DesiredOrder[] = []; const insufficientActive = this.applyInsufficientBalanceState(Date.now()); const canEnter = !this.rateLimit.shouldBlockEntries() && !insufficientActive; if (absPosition < EPS) { this.entryPricePendingLogged = false; if (canEnter) { desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false }); 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; desired.push({ side: closeSide, price: closePrice, amount: absPosition, reduceOnly: true }); } this.desiredOrders = desired; this.logDesiredOrders(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("maker"); await this.enforceRateLimitStop(); this.tradeLog.push("warn", t("log.maker.rateLimit429", { error: String(error) })); } else { this.tradeLog.push("error", t("log.maker.loopError", { error: String(error) })); } this.emitUpdate(); } finally { this.rateLimit.onCycleComplete(hadRateLimit); this.processing = false; } } private async enforceRateLimitStop(): Promise { const position = getPosition(this.accountSnapshot, this.config.symbol); if (Math.abs(position.positionAmt) < EPS) return; const { topBid, topAsk } = getTopPrices(this.depthSnapshot); if (topBid == null || topAsk == null) return; const priceDecimals = this.getPriceDecimals(); const closeBidPrice = formatPriceToString(topBid, priceDecimals); const closeAskPrice = formatPriceToString(topAsk, priceDecimals); await this.checkRisk(position, Number(closeBidPrice), Number(closeAskPrice)); await this.flushOrders(); } 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", t("log.maker.cleanOrdersStart")); this.initialOrderResetDone = true; return true; } catch (error) { if (isUnknownOrderError(error)) { this.tradeLog.push("order", t("log.maker.cleanOrdersMissing")); this.initialOrderResetDone = true; this.openOrders = []; this.emitUpdate(); return true; } this.tradeLog.push("error", t("log.maker.cleanOrdersFail", { error: String(error) })); return false; } } 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)); const { toCancel, toPlace } = makeOrderPlan(openOrders, targets); 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", t("log.maker.cancelMismatched", { side: order.side, price: order.price, reduceOnly: order.reduceOnly, }) ); }, () => { this.tradeLog.push("order", t("log.maker.cancelMissing")); this.pendingCancelOrders.delete(String(order.orderId)); this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); }, (error) => { this.tradeLog.push("error", t("log.maker.cancelFail", { 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; try { await placeOrder(this.orderContext, { openOrders: this.openOrders, side: target.side, price: target.price, amount: target.amount, reduceOnly: target.reduceOnly, guard: { markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice, maxPct: this.config.maxCloseSlippagePct, }, qtyStep: this.precision.qtyStep }); } catch (error) { if (isInsufficientBalanceError(error)) { this.registerInsufficientBalance(error); break; } this.tradeLog.push( "error", t("log.maker.placeFail", { side: target.side, price: target.price, error: extractMessage(error), }) ); } } } private async checkRisk(position: PositionSnapshot, bidPrice: number, askPrice: number): Promise { 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", t("log.maker.avgPending")); this.entryPricePendingLogged = true; } return; } this.entryPricePendingLogged = false; const pnl = computePositionPnl(position, bidPrice, askPrice); const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit); if (triggerStop) { // 价格操纵保护:只有平仓方向价格与标记价格在阈值内才允许市价平仓 const closeSideIsSell = position.positionAmt > 0; const closeSidePrice = closeSideIsSell ? bidPrice : askPrice; this.tradeLog.push( "stop", t("log.maker.stopTriggered", { direction: position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"), pnl: pnl.toFixed(4), }) ); try { await this.flushOrders(); await marketClose(this.orderContext, { openOrders: this.openOrders, side: position.positionAmt > 0 ? "SELL" : "BUY", quantity: absPosition, guard: { markPrice: position.markPrice, expectedPrice: Number(closeSidePrice) || null, maxPct: this.config.maxCloseSlippagePct, }, qtyStep: this.precision.qtyStep }); } catch (error) { if (isUnknownOrderError(error)) { this.tradeLog.push("order", t("log.maker.stopOrderMissing")); } else { this.tradeLog.push("error", t("log.maker.stopCloseFail", { 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, () => { // 成功撤销不记录日志,保持现有行为 }, () => { this.tradeLog.push("order", t("log.maker.orderMissing")); this.pendingCancelOrders.delete(String(order.orderId)); this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); }, (error) => { this.tradeLog.push("error", t("log.maker.cancelFail", { error: String(error) })); this.pendingCancelOrders.delete(String(order.orderId)); this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); } ); } } private getPriceDecimals(): number { const tick = Math.max(1e-9, this.precision.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", t("log.maker.updateHandlerError", { error: String(error) })); }); } catch (err) { this.tradeLog.push("error", t("log.maker.snapshotDispatchError", { error: String(err) })); } } private buildSnapshot(): MakerEngineSnapshot { const position = getPosition(this.accountSnapshot, this.config.symbol); 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 }, }; } private getReferencePrice(): number | null { return getMidOrLast(this.depthSnapshot, this.tickerSnapshot); } private logReadinessBlockers(): void { if (!this.feedStatus.account && !this.readinessLogged.account) { this.tradeLog.push("info", t("log.maker.waitAccount")); this.readinessLogged.account = true; } if (!this.feedStatus.depth && !this.readinessLogged.depth) { this.tradeLog.push("info", t("log.maker.waitDepth")); this.readinessLogged.depth = true; } if (!this.feedStatus.ticker && !this.readinessLogged.ticker) { this.tradeLog.push("info", t("log.maker.waitTicker")); this.readinessLogged.ticker = true; } if (!this.feedStatus.orders && !this.readinessLogged.orders) { this.tradeLog.push("info", t("log.maker.waitOrders")); this.readinessLogged.orders = true; } } private resetReadinessFlags(): void { this.readinessLogged = { account: false, depth: false, ticker: false, orders: false, }; } private logDesiredOrders(desired: DesiredOrder[]): void { if (!desired.length) { if (this.lastDesiredSummary !== "none") { this.tradeLog.push("info", t("log.maker.noTargets")); this.lastDesiredSummary = "none"; } return; } const summary = desired .map((order) => `${order.side}@${order.price}${order.reduceOnly ? "(RO)" : ""}`) .join(" | "); if (summary !== this.lastDesiredSummary) { this.tradeLog.push("info", t("log.maker.targetsSummary", { summary })); this.lastDesiredSummary = summary; } } private registerInsufficientBalance(error: unknown): void { const now = Date.now(); const detail = extractMessage(error); const alreadyActive = now < this.insufficientBalanceCooldownUntil; if (alreadyActive && detail === this.lastInsufficientMessage) { this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS; return; } this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS; this.lastInsufficientMessage = detail; const seconds = Math.ceil(INSUFFICIENT_BALANCE_COOLDOWN_MS / 1000); this.tradeLog.push("warn", t("log.maker.balanceThrottle", { seconds, detail })); this.insufficientBalanceNotified = true; } private applyInsufficientBalanceState(now: number): boolean { const active = now < this.insufficientBalanceCooldownUntil; if (!active && this.insufficientBalanceNotified) { this.tradeLog.push("info", t("log.maker.balanceResumed")); this.insufficientBalanceNotified = false; this.lastInsufficientMessage = null; } return active; } }