From 5aecaabb16f09aeac3e9b47c6e48845d5d2c173e Mon Sep 17 00:00:00 2001 From: discountry Date: Wed, 29 Jul 2026 21:17:58 +0800 Subject: [PATCH] refactor(core): give order functions a parameter object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit placeOrder took 13 positional arguments; the other five order functions took 9-13. The leading six — adapter, symbol, openOrders, locks, timers, pendings — were the same values at all 36 call sites, and every engine spelled them out again for each order it placed. Introduce Parameter Object: OrderContext holds what is fixed for an engine's lifetime (exposed once via a lazily-built this.orderContext), and each function takes a named request. A wrong argument order is now a compile error rather than a silently misrouted order. The type change surfaced dead weight: placeOrder's opts.priceTick was never read by its body, yet five engines passed it. Removed. Also finishes the PrecisionSyncer migration — grid-engine was the ninth copy and was missed last round, so it still carried the uncleared retry timer. Extract Function: normalizeQuantity replaces the round-down-but-never-to-zero block that appeared in all five order functions. 250 pass; tsc and oxlint clean. --- src/core/order-coordinator.ts | 246 ++++++++++++------------- src/strategy/grid-engine.ts | 127 +++++-------- src/strategy/guardian-engine.ts | 115 ++++++------ src/strategy/liquidity-maker-engine.ts | 122 +++++------- src/strategy/maker-engine.ts | 62 +++---- src/strategy/maker-points-engine.ts | 66 ++++--- src/strategy/offset-maker-engine.ts | 142 ++++++-------- src/strategy/swing-engine.ts | 82 ++++----- src/strategy/trend-engine.ts | 157 +++++++--------- tests/order-coordinator.test.ts | 131 +++++-------- 10 files changed, 542 insertions(+), 708 deletions(-) diff --git a/src/core/order-coordinator.ts b/src/core/order-coordinator.ts index da8720d..62685c8 100644 --- a/src/core/order-coordinator.ts +++ b/src/core/order-coordinator.ts @@ -22,6 +22,69 @@ type OrderGuardOptions = { maxPct?: number; }; +/** + * Everything about *where* an order goes, fixed for an engine's lifetime. + * These six values always travelled together as the leading positional + * parameters of every order function; bundling them keeps call sites readable + * and makes an argument-order mistake impossible. + */ +export interface OrderContext { + adapter: ExchangeAdapter; + symbol: string; + locks: OrderLockMap; + timers: OrderTimerMap; + pendings: OrderPendingMap; + log: LogHandler; +} + +interface OrderRequestBase { + /** Live orders, used to cancel same-type duplicates before placing. */ + openOrders: Order[]; + side: "BUY" | "SELL"; + /** Rejects the order when its price strays too far from the mark price. */ + guard?: OrderGuardOptions; + qtyStep?: number; +} + +export interface LimitOrderRequest extends OrderRequestBase { + /** String to preserve the exact tick the caller computed. */ + price: string; + amount: number; + reduceOnly?: boolean; + skipDedupe?: boolean; + slPrice?: number; + tpPrice?: number; + clientOrderId?: string; +} + +export interface MarketOrderRequest extends OrderRequestBase { + amount: number; + reduceOnly?: boolean; +} + +export interface StopLossOrderRequest extends OrderRequestBase { + stopPrice: number; + quantity: number; + /** Latest traded price; the stop is rejected when it is already through it. */ + lastPrice: number | null; + priceTick?: number; +} + +export interface TrailingStopOrderRequest extends OrderRequestBase { + activationPrice: number; + quantity: number; + callbackRate: number; + priceTick?: number; +} + +export interface MarketCloseRequest extends OrderRequestBase { + quantity: number; +} + +/** Step assumed when the caller does not know the venue's own. */ +const DEFAULT_QTY_STEP = 0.001; +const DEFAULT_PRICE_TICK = 0.1; + function enforceMarkPriceGuard( side: "BUY" | "SELL", toCheckPrice: number | null | undefined, @@ -48,6 +111,13 @@ function enforceMarkPriceGuard( return true; } +/** Rounds down to the venue's step, but never to zero — a sub-step size is kept as-is. */ +function normalizeQuantity(amount: number, qtyStep: number): number { + const raw = Math.abs(amount); + const rounded = roundQtyDownToStep(raw, qtyStep); + return rounded > 0 ? rounded : raw; +} + export function isOperating(locks: OrderLockMap, type: string): boolean { return Boolean(locks[type]); } @@ -86,16 +156,12 @@ export function unlockOperating( } export async function deduplicateOrders( - adapter: ExchangeAdapter, - symbol: string, + ctx: OrderContext, openOrders: Order[], - locks: OrderLockMap, - timers: OrderTimerMap, - pendings: OrderPendingMap, type: string, - side: string, - log: LogHandler + side: string ): Promise { + const { adapter, symbol, locks, timers, pendings, log } = ctx; // Treat STOP orders on some exchanges (e.g., Lighter) as LIMIT with stopPrice populated. const sameTypeOrders = openOrders.filter((o) => { const normalizedType = String(o.type).toUpperCase(); @@ -128,63 +194,42 @@ export async function deduplicateOrders( } } -type PlaceOrderOptions = { - priceTick: number; - qtyStep: number; - skipDedupe?: boolean; - slPrice?: number; - tpPrice?: number; - clientOrderId?: string; -}; - export async function placeOrder( - adapter: ExchangeAdapter, - symbol: string, - openOrders: Order[], - locks: OrderLockMap, - timers: OrderTimerMap, - pendings: OrderPendingMap, - side: "BUY" | "SELL", - price: string, // 改为字符串价格 - amount: number, - log: LogHandler, - reduceOnly = false, - guard?: OrderGuardOptions, - opts?: PlaceOrderOptions + ctx: OrderContext, + request: LimitOrderRequest ): Promise { + const { locks, timers, pendings, log } = ctx; + const { side, openOrders, guard, reduceOnly = false } = request; const type = "LIMIT"; if (isOperating(locks, type)) return; - const priceNum = Number(price); + const priceNum = Number(request.price); if (!enforceMarkPriceGuard(side, priceNum, guard, log, "限价单")) return; - const qtyStep = opts?.qtyStep ?? 0.001; - const rawQuantity = Math.abs(amount); - const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep); - const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity; + const quantity = normalizeQuantity(request.amount, request.qtyStep ?? DEFAULT_QTY_STEP); if (quantity <= 0) { log("error", "限价单数量无效,跳过下单"); return; } - if (!opts?.skipDedupe) { - await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); + if (!request.skipDedupe) { + await deduplicateOrders(ctx, openOrders, type, side); } lockOperating(locks, timers, pendings, type, log); try { const closePosition = reduceOnly ? true : undefined; const order = await routeLimitOrder({ - adapter, - symbol, + adapter: ctx.adapter, + symbol: ctx.symbol, side, quantity, price: priceNum, timeInForce: reduceOnly ? "GTC" : "GTX", reduceOnly: reduceOnly ? true : undefined, closePosition, - slPrice: opts?.slPrice, - tpPrice: opts?.tpPrice, - clientOrderId: opts?.clientOrderId, + slPrice: request.slPrice, + tpPrice: request.tpPrice, + clientOrderId: request.clientOrderId, }); pendings[type] = String(order.orderId); - log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}${opts?.slPrice ? ` sl=${opts.slPrice}` : ""}`); + log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}${request.slPrice ? ` sl=${request.slPrice}` : ""}`); return order; } catch (err) { unlockOperating(locks, timers, pendings, type); @@ -197,37 +242,26 @@ export async function placeOrder( } export async function placeMarketOrder( - adapter: ExchangeAdapter, - symbol: string, - openOrders: Order[], - locks: OrderLockMap, - timers: OrderTimerMap, - pendings: OrderPendingMap, - side: "BUY" | "SELL", - amount: number, - log: LogHandler, - reduceOnly = false, - guard?: OrderGuardOptions, - opts?: { qtyStep: number } + ctx: OrderContext, + request: MarketOrderRequest ): Promise { + const { locks, timers, pendings, log } = ctx; + const { side, openOrders, guard, reduceOnly = false } = request; const type = "MARKET"; if (isOperating(locks, type)) return; if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价单")) return; - const qtyStep = opts?.qtyStep ?? 0.001; - const rawQuantity = Math.abs(amount); - const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep); - const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity; + const quantity = normalizeQuantity(request.amount, request.qtyStep ?? DEFAULT_QTY_STEP); if (quantity <= 0) { log("error", "市价单数量无效,跳过下单"); return; } - await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); + await deduplicateOrders(ctx, openOrders, type, side); lockOperating(locks, timers, pendings, type, log); try { const closePosition = reduceOnly ? true : undefined; const order = await routeMarketOrder({ - adapter, - symbol, + adapter: ctx.adapter, + symbol: ctx.symbol, side, quantity, reduceOnly: reduceOnly ? true : undefined, @@ -247,20 +281,11 @@ export async function placeMarketOrder( } export async function placeStopLossOrder( - adapter: ExchangeAdapter, - symbol: string, - openOrders: Order[], - locks: OrderLockMap, - timers: OrderTimerMap, - pendings: OrderPendingMap, - side: "BUY" | "SELL", - stopPrice: number, - quantity: number, - lastPrice: number | null, - log: LogHandler, - guard?: OrderGuardOptions, - opts?: { priceTick: number; qtyStep: number } + ctx: OrderContext, + request: StopLossOrderRequest ): Promise { + const { locks, timers, pendings, log } = ctx; + const { side, openOrders, guard, stopPrice, lastPrice } = request; const type = "STOP_MARKET"; if (isOperating(locks, type)) return; if (!enforceMarkPriceGuard(side, stopPrice, guard, log, "止损单")) return; @@ -274,24 +299,20 @@ export async function placeStopLossOrder( return; } } - const priceTick = opts?.priceTick ?? 0.1; - const qtyStep = opts?.qtyStep ?? 0.001; - const normalizedStop = roundDownToTick(stopPrice, priceTick); - const rawQuantity = Math.abs(quantity); - const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep); - const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity; + const normalizedStop = roundDownToTick(stopPrice, request.priceTick ?? DEFAULT_PRICE_TICK); + const normalizedQty = normalizeQuantity(request.quantity, request.qtyStep ?? DEFAULT_QTY_STEP); if (normalizedQty <= 0) { log("error", "止损单数量无效,跳过下单"); return; } // Avoid forcing price for STOP_MARKET globally; keep this exchange-specific in gateways - await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); + await deduplicateOrders(ctx, openOrders, type, side); lockOperating(locks, timers, pendings, type, log); try { const order = await routeStopOrder({ - adapter, - symbol, + adapter: ctx.adapter, + symbol: ctx.symbol, side, quantity: normalizedQty, stopPrice: normalizedStop, @@ -314,20 +335,11 @@ export async function placeStopLossOrder( } export async function placeTrailingStopOrder( - adapter: ExchangeAdapter, - symbol: string, - openOrders: Order[], - locks: OrderLockMap, - timers: OrderTimerMap, - pendings: OrderPendingMap, - side: "BUY" | "SELL", - activationPrice: number, - quantity: number, - callbackRate: number, - log: LogHandler, - guard?: OrderGuardOptions, - opts?: { priceTick: number; qtyStep: number } + ctx: OrderContext, + request: TrailingStopOrderRequest ): Promise { + const { adapter, locks, timers, pendings, log } = ctx; + const { side, openOrders, guard, activationPrice, callbackRate } = request; const type = "TRAILING_STOP_MARKET"; if (isOperating(locks, type)) return; if (!adapter.supportsTrailingStops()) { @@ -335,22 +347,18 @@ export async function placeTrailingStopOrder( return; } if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return; - const priceTick = opts?.priceTick ?? 0.1; - const qtyStep = opts?.qtyStep ?? 0.001; - const normalizedActivation = roundDownToTick(activationPrice, priceTick); - const rawQuantity = Math.abs(quantity); - const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep); - const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity; + const normalizedActivation = roundDownToTick(activationPrice, request.priceTick ?? DEFAULT_PRICE_TICK); + const normalizedQty = normalizeQuantity(request.quantity, request.qtyStep ?? DEFAULT_QTY_STEP); if (normalizedQty <= 0) { log("error", "动态止盈单数量无效,跳过下单"); return; } - await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); + await deduplicateOrders(ctx, openOrders, type, side); lockOperating(locks, timers, pendings, type, log); try { const order = await routeTrailingStopOrder({ adapter, - symbol, + symbol: ctx.symbol, side, quantity: normalizedQty, activationPrice: normalizedActivation, @@ -374,28 +382,18 @@ export async function placeTrailingStopOrder( } } -export async function marketClose( - adapter: ExchangeAdapter, - symbol: string, - openOrders: Order[], - locks: OrderLockMap, - timers: OrderTimerMap, - pendings: OrderPendingMap, - side: "BUY" | "SELL", - quantity: number, - log: LogHandler, - guard?: OrderGuardOptions, - opts?: { qtyStep: number } -): Promise { +export async function marketClose(ctx: OrderContext, request: MarketCloseRequest): Promise { + const { locks, timers, pendings, log } = ctx; + const { side, openOrders, guard, qtyStep } = request; const type = "MARKET"; if (isOperating(locks, type)) return; if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return; - const qtyStep = opts?.qtyStep; - const rawQuantity = Math.abs(quantity); - const normalizedQtyRaw = qtyStep != null ? roundQtyDownToStep(rawQuantity, qtyStep) : rawQuantity; - let normalizedQty = normalizedQtyRaw > 0 ? normalizedQtyRaw : rawQuantity; + const rawQuantity = Math.abs(request.quantity); + let normalizedQty = qtyStep != null ? normalizeQuantity(rawQuantity, qtyStep) : rawQuantity; if (qtyStep != null) { + // A step-rounded close that is within rounding noise of the real position + // would leave dust behind; close the exact amount instead. const epsilon = Math.max(qtyStep * 1e-4, 1e-10); if (Math.abs(rawQuantity - normalizedQty) <= epsilon) { normalizedQty = rawQuantity; @@ -406,12 +404,12 @@ export async function marketClose( return; } - await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); + await deduplicateOrders(ctx, openOrders, type, side); lockOperating(locks, timers, pendings, type, log); try { const order = await routeCloseOrder({ - adapter, - symbol, + adapter: ctx.adapter, + symbol: ctx.symbol, side, quantity: normalizedQty, reduceOnly: true, diff --git a/src/strategy/grid-engine.ts b/src/strategy/grid-engine.ts index 0ecc9af..a72edc1 100644 --- a/src/strategy/grid-engine.ts +++ b/src/strategy/grid-engine.ts @@ -10,11 +10,13 @@ import { placeOrder, placeStopLossOrder, unlockOperating, + type OrderContext, type OrderLockMap, type OrderPendingMap, type OrderTimerMap, } from "../core/order-coordinator"; import { StrategyEventEmitter } from "./common/event-emitter"; +import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer"; import { safeSubscribe, type LogHandler } from "./common/subscriptions"; import { clearGridState, loadGridState, saveGridState } from "./common/grid-storage"; import { @@ -170,7 +172,7 @@ export class GridEngine { private savePending = false; private uncoveredQty = 0; private desiredOrders: DesiredGridOrder[] = []; - private precisionSync: Promise | null = null; + private readonly precision: PrecisionSyncer; constructor( private readonly config: GridConfig, @@ -187,11 +189,25 @@ export class GridEngine { this.stopReason = "配置无效,已暂停网格"; this.log("error", this.stopReason); } - this.syncPrecision(); + this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, this.log); + this.precision.start(); this.bootstrap(); this.setupConnectionProtection(); } + /** 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.pendings, + log: (type, detail) => this.tradeLog.push(type, detail), + }); + } + private orderContextCache: OrderContext | null = null; + start(): void { if (this.timer || !this.running) { if (!this.timer && !this.running) { @@ -209,6 +225,7 @@ export class GridEngine { clearInterval(this.timer); this.timer = null; } + this.precision.stop(); } on(event: GridEvent, listener: GridListener): void { @@ -277,37 +294,6 @@ export class GridEngine { // Precision sync // ----------------------------------------------------------------------- - 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; - 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}`); - } - }) - .catch((error) => { - this.log("error", `同步精度失败: ${extractMessage(error)}`); - this.precisionSync = null; - setTimeout(() => this.syncPrecision(), 2000); - }); - } - // ----------------------------------------------------------------------- // Feed subscriptions / connection events // ----------------------------------------------------------------------- @@ -744,26 +730,16 @@ export class GridEngine { const ordersVersionBeforePlace = this.ordersVersion; try { this.lastLimitAttemptAt = now; - placed = await placeOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pendings, - action.side, - action.price, - action.qty, - this.log, - isEntry ? false : this.config.useReduceOnlyForExit, - undefined, - { - priceTick: this.config.priceTick, - qtyStep: this.config.qtyStep, - skipDedupe: true, - clientOrderId, - } - ); + placed = await placeOrder(this.orderContext, { + openOrders: this.openOrders, + side: action.side, + price: action.price, + amount: action.qty, + reduceOnly: isEntry ? false : this.config.useReduceOnlyForExit, + qtyStep: this.config.qtyStep, + skipDedupe: true, + clientOrderId, + }); } catch (error) { this.log("error", `挂单失败 (${action.side} @ ${action.price}): ${extractMessage(error)}`); } @@ -829,23 +805,17 @@ export class GridEngine { } } try { - await marketClose( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pendings, - side, - qty, - this.log, - { + await marketClose(this.orderContext, { + openOrders: this.openOrders, + side: side, + quantity: qty, + guard: { markPrice: mark, expectedPrice: Number.isFinite(closeSidePrice) ? closeSidePrice : null, maxPct: limitPct > 0 ? limitPct : undefined, }, - { qtyStep: this.config.qtyStep } - ); + qtyStep: this.config.qtyStep + }); this.log("close", `市价平仓 ${side} ${qty} (${reason})`); return true; } catch (error) { @@ -971,21 +941,16 @@ export class GridEngine { const lastPrice = Number(this.tickerSnapshot?.lastPrice); try { - const placed = await placeStopLossOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pendings, - desired.side, - desired.stopPrice, - Math.abs(this.position.positionAmt), - Number.isFinite(lastPrice) ? lastPrice : price, - this.log, - undefined, - { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } - ); + const placed = await placeStopLossOrder(this.orderContext, { + openOrders: this.openOrders, + side: desired.side, + stopPrice: desired.stopPrice, + quantity: Math.abs(this.position.positionAmt), + lastPrice: Number.isFinite(lastPrice) ? lastPrice : price, + guard: undefined, + priceTick: this.config.priceTick, + qtyStep: this.config.qtyStep + }); if (placed?.orderId != null) { state.exchangeStop = { orderId: String(placed.orderId), diff --git a/src/strategy/guardian-engine.ts b/src/strategy/guardian-engine.ts index 1e9bcd2..c7b6968 100644 --- a/src/strategy/guardian-engine.ts +++ b/src/strategy/guardian-engine.ts @@ -15,7 +15,7 @@ import { placeTrailingStopOrder, unlockOperating, } from "../core/order-coordinator"; -import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator"; +import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator"; import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; import { extractMessage, isUnknownOrderError } from "../utils/errors"; import { formatPriceToString } from "../utils/math"; @@ -76,6 +76,19 @@ export class GuardianEngine { 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(() => { @@ -390,24 +403,19 @@ export class GuardianEngine { if (quantity <= minQty) { return; } - await placeStopLossOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pending, - side, - stopPrice, - quantity, - lastPrice, - (type, detail) => this.tradeLog.push(type, detail), - { + 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 } - ); + priceTick: this.config.priceTick, + qtyStep: this.config.qtyStep + }); this.lastStopAttempt.side = side; this.lastStopAttempt.price = stopPrice; this.lastStopAttempt.at = now; @@ -449,24 +457,19 @@ export class GuardianEngine { if (quantity <= minQty) { return; } - const order = await placeStopLossOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pending, - side, - nextStopPrice, - quantity, - lastPrice, - (type, detail) => this.tradeLog.push(type, detail), - { + 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 } - ); + priceTick: this.config.priceTick, + qtyStep: this.config.qtyStep + }); if (order) { this.tradeLog.push( "stop", @@ -484,24 +487,19 @@ export class GuardianEngine { if (quantity <= minQty) { return; } - const restored = await placeStopLossOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pending, - side, - Number.isFinite(existingStopPrice) ? existingStopPrice : nextStopPrice, - quantity, - lastPrice, - (type, detail) => this.tradeLog.push(type, detail), - { + const restored = await placeStopLossOrder(this.orderContext, { + openOrders: this.openOrders, + side: side, + stopPrice: Number.isFinite(existingStopPrice) ? existingStopPrice : nextStopPrice, + quantity: quantity, + lastPrice: lastPrice, + guard: { markPrice: position.markPrice, maxPct: this.config.maxCloseSlippagePct, }, - { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } - ); + priceTick: this.config.priceTick, + qtyStep: this.config.qtyStep + }); if (restored && Number.isFinite(existingStopPrice)) { this.tradeLog.push( "order", @@ -525,24 +523,19 @@ export class GuardianEngine { return; } try { - await placeTrailingStopOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pending, - side, - activationPrice, - quantity, - this.config.trailingCallbackRate, - (type, detail) => this.tradeLog.push(type, detail), - { + 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 } - ); + priceTick: this.config.priceTick, + qtyStep: this.config.qtyStep + }); } catch (err) { this.tradeLog.push("error", t("log.guardian.trailingFail", { error: String(err) })); } diff --git a/src/strategy/liquidity-maker-engine.ts b/src/strategy/liquidity-maker-engine.ts index 19a0e42..aabf8ca 100644 --- a/src/strategy/liquidity-maker-engine.ts +++ b/src/strategy/liquidity-maker-engine.ts @@ -21,7 +21,7 @@ import { placeOrder, unlockOperating, } from "../core/order-coordinator"; -import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator"; +import type { OrderContext, 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"; @@ -151,6 +151,19 @@ export class LiquidityMakerEngine { 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(() => { @@ -698,17 +711,11 @@ export class LiquidityMakerEngine { 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), - { + await marketClose(this.orderContext, { + openOrders: this.openOrders, + side: side, + quantity: absPosition, + guard: { markPrice: position.markPrice, expectedPrice: side === "SELL" @@ -716,8 +723,8 @@ export class LiquidityMakerEngine { : (closeBidPrice != null ? Number(closeBidPrice) : null), maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.precision.qtyStep } - ); + qtyStep: this.precision.qtyStep + }); } catch (error) { if (isUnknownOrderError(error)) { this.tradeLog.push("order", "限频强制平仓时订单已不存在"); @@ -886,27 +893,18 @@ export class LiquidityMakerEngine { } 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, - { + await placeOrder(this.orderContext, { + openOrders: this.openOrders, + side: target.side, + price: target.price, + amount: target.amount, + reduceOnly: reduceOnlyFlag, + guard: { markPrice: this.getPositionSnapshot().markPrice, maxPct: this.config.maxCloseSlippagePct, }, - { - priceTick: this.precision.priceTick, - qtyStep: this.precision.qtyStep, - } - ); + qtyStep: this.precision.qtyStep + }); // Record last placed entry order timing and price if (!target.reduceOnly) { this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() }; @@ -955,23 +953,17 @@ export class LiquidityMakerEngine { // 尽力撤销所有未完成挂单,避免锁定基础资产导致余额不足 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), - { + await marketClose(this.orderContext, { + openOrders: this.openOrders, + side: "SELL", + quantity: absPosition, + guard: { markPrice: position.markPrice, expectedPrice: bidPrice || null, maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.precision.qtyStep } - ); + qtyStep: this.precision.qtyStep + }); } catch (error) { if (isRateLimitError(error)) throw error; if (isUnknownOrderError(error)) { @@ -1005,23 +997,17 @@ export class LiquidityMakerEngine { ); 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), - { + await marketClose(this.orderContext, { + openOrders: this.openOrders, + side: position.positionAmt > 0 ? "SELL" : "BUY", + quantity: absPosition, + guard: { markPrice: position.markPrice, expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null, maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.precision.qtyStep } - ); + qtyStep: this.precision.qtyStep + }); } catch (error) { if (isUnknownOrderError(error)) { this.tradeLog.push("order", "止损平仓时订单已不存在"); @@ -1253,17 +1239,11 @@ export class LiquidityMakerEngine { 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), - { + await marketClose(this.orderContext, { + openOrders: this.openOrders, + side: target.side, + quantity: absQty, + guard: { markPrice: position.markPrice, expectedPrice: target.side === "SELL" @@ -1271,8 +1251,8 @@ export class LiquidityMakerEngine { : (topAsk != null ? Number(topAsk) : null), maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.precision.qtyStep } - ); + qtyStep: this.precision.qtyStep + }); this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`); return true; } catch (closeError) { diff --git a/src/strategy/maker-engine.ts b/src/strategy/maker-engine.ts index 9bc4f73..408616c 100644 --- a/src/strategy/maker-engine.ts +++ b/src/strategy/maker-engine.ts @@ -20,7 +20,7 @@ import { placeOrder, unlockOperating, } from "../core/order-coordinator"; -import type { OrderLockMap, OrderPendingMap, OrderTimerMap } 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"; @@ -127,6 +127,19 @@ export class MakerEngine { 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(() => { @@ -439,27 +452,18 @@ export class MakerEngine { if (!target) continue; if (target.amount < EPS) continue; try { - 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), - target.reduceOnly, - { + 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, }, - { - priceTick: this.precision.priceTick, - qtyStep: this.precision.qtyStep, - } - ); + qtyStep: this.precision.qtyStep + }); } catch (error) { if (isInsufficientBalanceError(error)) { this.registerInsufficientBalance(error); @@ -507,23 +511,17 @@ export class MakerEngine { ); 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), - { + 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 } - ); + qtyStep: this.precision.qtyStep + }); } catch (error) { if (isUnknownOrderError(error)) { this.tradeLog.push("order", t("log.maker.stopOrderMissing")); diff --git a/src/strategy/maker-points-engine.ts b/src/strategy/maker-points-engine.ts index 35f838c..fa17c98 100644 --- a/src/strategy/maker-points-engine.ts +++ b/src/strategy/maker-points-engine.ts @@ -19,7 +19,7 @@ import { placeOrder, unlockOperating, } from "../core/order-coordinator"; -import type { OrderLockMap, OrderPendingMap, OrderTimerMap } 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"; @@ -244,6 +244,19 @@ export class MakerPointsEngine { 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; // 初始化数据时间戳 @@ -1046,26 +1059,17 @@ export class MakerPointsEngine { : target.side === "BUY" ? priceNum - 1 : priceNum + 1; - 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), - target.reduceOnly, - undefined, - { - priceTick: this.precision.priceTick, - qtyStep: this.precision.qtyStep, - skipDedupe: true, - slPrice, - } - ); + await placeOrder(this.orderContext, { + openOrders: this.openOrders, + side: target.side, + price: target.price, + amount: target.amount, + reduceOnly: target.reduceOnly, + guard: undefined, + qtyStep: this.precision.qtyStep, + skipDedupe: true, + slPrice + }); } catch (error) { if (isInsufficientBalanceError(error)) { this.registerInsufficientBalance(error); @@ -1231,19 +1235,13 @@ export class MakerPointsEngine { await this.flushOrders(); // 执行市价平仓 - await marketClose( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pending, - side, - currentAbsPosition, - (type, detail) => this.tradeLog.push(type, detail), - undefined, - { qtyStep: this.precision.qtyStep } - ); + await marketClose(this.orderContext, { + openOrders: this.openOrders, + side: side, + quantity: currentAbsPosition, + guard: undefined, + qtyStep: this.precision.qtyStep + }); // 等待一小段时间让账户数据更新 await this.sleep(STOP_LOSS_RETRY_INTERVAL_MS); diff --git a/src/strategy/offset-maker-engine.ts b/src/strategy/offset-maker-engine.ts index 1c79f3d..bbc31eb 100644 --- a/src/strategy/offset-maker-engine.ts +++ b/src/strategy/offset-maker-engine.ts @@ -22,7 +22,7 @@ import { placeOrder, unlockOperating, } from "../core/order-coordinator"; -import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator"; +import type { OrderContext, 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"; @@ -141,6 +141,19 @@ export class OffsetMakerEngine { 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(() => { @@ -551,17 +564,11 @@ export class OffsetMakerEngine { 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), - { + await marketClose(this.orderContext, { + openOrders: this.openOrders, + side: side, + quantity: absPosition, + guard: { markPrice: position.markPrice, expectedPrice: side === "SELL" @@ -569,8 +576,8 @@ export class OffsetMakerEngine { : (closeBidPrice != null ? Number(closeBidPrice) : null), maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.precision.qtyStep } - ); + qtyStep: this.precision.qtyStep + }); } catch (error) { if (isUnknownOrderError(error)) { this.tradeLog.push("order", "限频强制平仓时订单已不存在"); @@ -644,23 +651,17 @@ export class OffsetMakerEngine { ); 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), - { + await marketClose(this.orderContext, { + openOrders: this.openOrders, + side: side, + quantity: absPosition, + guard: { markPrice: position.markPrice, expectedPrice: Number(closeSidePrice) || null, maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.precision.qtyStep } - ); + qtyStep: this.precision.qtyStep + }); } catch (error) { if (isUnknownOrderError(error)) { this.tradeLog.push("order", "深度不平衡平仓时订单已不存在"); @@ -747,27 +748,18 @@ export class OffsetMakerEngine { } 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, - { + await placeOrder(this.orderContext, { + openOrders: this.openOrders, + side: target.side, + price: target.price, + amount: target.amount, + reduceOnly: reduceOnlyFlag, + guard: { markPrice: this.getPositionSnapshot().markPrice, maxPct: this.config.maxCloseSlippagePct, }, - { - priceTick: this.precision.priceTick, - qtyStep: this.precision.qtyStep, - } - ); + qtyStep: this.precision.qtyStep + }); // Record last placed entry order timing and price if (!target.reduceOnly) { this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() }; @@ -816,23 +808,17 @@ export class OffsetMakerEngine { // 尽力撤销所有未完成挂单,避免锁定基础资产导致余额不足 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), - { + await marketClose(this.orderContext, { + openOrders: this.openOrders, + side: "SELL", + quantity: absPosition, + guard: { markPrice: position.markPrice, expectedPrice: bidPrice || null, maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.precision.qtyStep } - ); + qtyStep: this.precision.qtyStep + }); } catch (error) { if (isRateLimitError(error)) throw error; if (isUnknownOrderError(error)) { @@ -866,23 +852,17 @@ export class OffsetMakerEngine { ); 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), - { + await marketClose(this.orderContext, { + openOrders: this.openOrders, + side: position.positionAmt > 0 ? "SELL" : "BUY", + quantity: absPosition, + guard: { markPrice: position.markPrice, expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null, maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.precision.qtyStep } - ); + qtyStep: this.precision.qtyStep + }); } catch (error) { if (isUnknownOrderError(error)) { this.tradeLog.push("order", "止损平仓时订单已不存在"); @@ -1123,17 +1103,11 @@ export class OffsetMakerEngine { 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), - { + await marketClose(this.orderContext, { + openOrders: this.openOrders, + side: target.side, + quantity: absQty, + guard: { markPrice: position.markPrice, expectedPrice: target.side === "SELL" @@ -1141,8 +1115,8 @@ export class OffsetMakerEngine { : (topAsk != null ? Number(topAsk) : null), maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.precision.qtyStep } - ); + qtyStep: this.precision.qtyStep + }); this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`); return true; } catch (closeError) { diff --git a/src/strategy/swing-engine.ts b/src/strategy/swing-engine.ts index e33eaf5..77adf71 100644 --- a/src/strategy/swing-engine.ts +++ b/src/strategy/swing-engine.ts @@ -2,7 +2,7 @@ import type { ExchangeAdapter } from "../exchanges/adapter"; import type { AccountSnapshot, Depth, Order, Ticker } from "../exchanges/types"; import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; import { marketClose, placeMarketOrder, placeStopLossOrder, unlockOperating } from "../core/order-coordinator"; -import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator"; +import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator"; import { extractMessage, isRateLimitError, isUnknownOrderError } from "../utils/errors"; import { getPosition, type PositionSnapshot } from "../utils/strategy"; import { computePositionPnl } from "../utils/pnl"; @@ -136,6 +136,19 @@ export class SwingEngine { 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(() => { @@ -348,24 +361,18 @@ export class SwingEngine { if (Math.abs(position.positionAmt) > EPS) { return; } - await placeMarketOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pending, - side, - this.config.tradeAmount, - (type, detail) => this.tradeLog.push(type, detail), - false, - { + await placeMarketOrder(this.orderContext, { + openOrders: this.openOrders, + side: side, + amount: this.config.tradeAmount, + reduceOnly: false, + guard: { markPrice: position.markPrice, expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null, maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.config.qtyStep } - ); + qtyStep: this.config.qtyStep + }); this.tradeLog.push("open", `${reason}: ${side} (market)`); } catch (err) { this.tradeLog.push("error", `Open failed: ${extractMessage(err)}`); @@ -380,23 +387,17 @@ export class SwingEngine { side === "SELL" ? Number(this.depthSnapshot?.bids?.[0]?.[0]) : Number(this.depthSnapshot?.asks?.[0]?.[0]); - await marketClose( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pending, - side, - Math.abs(position.positionAmt), - (type, detail) => this.tradeLog.push(type, detail), - { + await marketClose(this.orderContext, { + openOrders: this.openOrders, + side: side, + quantity: Math.abs(position.positionAmt), + guard: { markPrice: position.markPrice, expectedPrice: Number.isFinite(expected) ? expected : Number(this.tickerSnapshot?.lastPrice) || null, maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.config.qtyStep } - ); + qtyStep: this.config.qtyStep + }); this.tradeLog.push("close", `${reason}: ${side} (market close)`); } catch (err) { if (isUnknownOrderError(err)) { @@ -459,24 +460,19 @@ export class SwingEngine { try { const qty = Math.abs(position.positionAmt); - await placeStopLossOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pending, - stopSide, - stopPrice, - qty, - lastPrice, - (type, detail) => this.tradeLog.push(type, detail), - { + await placeStopLossOrder(this.orderContext, { + openOrders: this.openOrders, + side: stopSide, + stopPrice: stopPrice, + quantity: qty, + lastPrice: lastPrice, + guard: { markPrice: position.markPrice, maxPct: this.config.maxCloseSlippagePct, }, - { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } - ); + priceTick: this.config.priceTick, + qtyStep: this.config.qtyStep + }); this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() }; } catch (err) { this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() }; diff --git a/src/strategy/trend-engine.ts b/src/strategy/trend-engine.ts index 4ba2013..50323e2 100644 --- a/src/strategy/trend-engine.ts +++ b/src/strategy/trend-engine.ts @@ -25,7 +25,7 @@ import { placeTrailingStopOrder, unlockOperating, } from "../core/order-coordinator"; -import type { OrderLockMap, OrderPendingMap, OrderTimerMap } 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"; @@ -139,6 +139,19 @@ export class TrendEngine { 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(() => { @@ -490,24 +503,18 @@ export class TrendEngine { private async submitMarketOrder(side: "BUY" | "SELL", price: number, reason: string): Promise { try { - await placeMarketOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pending, - side, - this.config.tradeAmount, - (type, detail) => this.tradeLog.push(type, detail), - false, - { + 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 } - ); + qtyStep: this.config.qtyStep + }); this.tradeLog.push("open", `${reason}: ${side} @ ${price}`); this.lastOpenPlan = { side, price }; } catch (err) { @@ -748,17 +755,11 @@ export class TrendEngine { return { closed: false, pnl }; } } - await marketClose( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pending, - direction === "long" ? "SELL" : "BUY", - Math.abs(position.positionAmt), - (type, detail) => this.tradeLog.push(type, detail), - { + 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" @@ -767,8 +768,8 @@ export class TrendEngine { ) || null, maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.config.qtyStep } - ); + qtyStep: this.config.qtyStep + }); result.closed = true; this.tradeLog.push("close", t("log.trend.stopClose", { side: direction === "long" ? "SELL" : "BUY" })); // 记录止损时间以便短期内抑制再次入场 @@ -811,24 +812,19 @@ export class TrendEngine { if (quantity <= minQty) { return; } - await placeStopLossOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pending, - side, - stopPrice, - quantity, - lastPrice, - (type, detail) => this.tradeLog.push(type, detail), - { + 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 } - ); + 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) })); @@ -871,24 +867,19 @@ export class TrendEngine { if (quantity <= minQty) { return; } - const order = await placeStopLossOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pending, - side, - nextStopPrice, - quantity, - lastPrice, - (type, detail) => this.tradeLog.push(type, detail), - { + 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 } - ); + priceTick: this.config.priceTick, + qtyStep: this.config.qtyStep + }); if (order) { this.tradeLog.push( "stop", @@ -914,24 +905,19 @@ export class TrendEngine { (side === "SELL" && existingStopPrice >= lastPrice) || (side === "BUY" && existingStopPrice <= lastPrice); if (!restoreInvalid) { - const restored = await placeStopLossOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pending, - side, - existingStopPrice, - quantity, - lastPrice, - (t, d) => this.tradeLog.push(t, d), - { + 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 } - ); + priceTick: this.config.priceTick, + qtyStep: this.config.qtyStep + }); if (restored) { this.tradeLog.push( "order", @@ -959,24 +945,19 @@ export class TrendEngine { return; } try { - await placeTrailingStopOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pending, - side, - activationPrice, - quantity, - this.config.trailingCallbackRate, - (type, detail) => this.tradeLog.push(type, detail), - { + 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 } - ); + priceTick: this.config.priceTick, + qtyStep: this.config.qtyStep + }); } catch (err) { this.tradeLog.push("error", t("log.trend.trailingFail", { error: String(err) })); } diff --git a/tests/order-coordinator.test.ts b/tests/order-coordinator.test.ts index 3c21f91..539fba4 100644 --- a/tests/order-coordinator.test.ts +++ b/tests/order-coordinator.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it, vi, beforeEach, afterAll } from "vitest"; import type { ExchangeAdapter } from "../src/exchanges/adapter"; import type { Order } from "../src/exchanges/types"; -import type { OrderLockMap, OrderTimerMap, OrderPendingMap } from "../src/core/order-coordinator"; +import type { + OrderContext, + OrderLockMap, + OrderPendingMap, + OrderTimerMap, +} from "../src/core/order-coordinator"; import { deduplicateOrders, placeOrder, @@ -60,130 +65,76 @@ describe("order-coordinator", () => { process.env.EXCHANGE = originalExchange; }); - it("deduplicates orders by type and side", async () => { + /** One order context plus handles on the pieces the assertions poke at. */ + function createContext() { const adapter = createMockExchange(); const locks: OrderLockMap = {}; const timers: OrderTimerMap = {}; const pending: OrderPendingMap = {}; const log = vi.fn(); + const ctx: OrderContext = { adapter, symbol: "BTCUSDT", locks, timers, pendings: pending, log }; + return { ctx, adapter, locks, timers, pending, log }; + } + + it("deduplicates orders by type and side", async () => { + const { ctx, adapter, log } = createContext(); const openOrders: Order[] = [ { ...baseOrder, orderId: 1 }, { ...baseOrder, orderId: 2 }, ]; - await deduplicateOrders(adapter, "BTCUSDT", openOrders, locks, timers, pending, "LIMIT", "BUY", log); + await deduplicateOrders(ctx, openOrders, "LIMIT", "BUY"); expect(adapter.cancelOrders).toHaveBeenCalledWith({ symbol: "BTCUSDT", orderIdList: [2] }); expect(log).toHaveBeenCalledWith("order", expect.stringContaining("去重撤销重复")); }); it("places limit orders and records pending id", async () => { - const adapter = createMockExchange(); - const locks: OrderLockMap = {}; - const timers: OrderTimerMap = {}; - const pending: OrderPendingMap = {}; - const log = vi.fn(); - await placeOrder( - adapter, - "BTCUSDT", - [], - locks, - timers, - pending, - "BUY", - "100", - 1, - log, - false - ); + const { ctx, adapter, pending } = createContext(); + await placeOrder(ctx, { openOrders: [], side: "BUY", price: "100", amount: 1, reduceOnly: false }); expect(adapter.createOrder).toHaveBeenCalled(); expect(pending.MARKET).toBeUndefined(); expect(pending.LIMIT).toBe(String(baseOrder.orderId)); }); it("places market order and unlocks after completion", async () => { - const adapter = createMockExchange(); - const locks: OrderLockMap = {}; - const timers: OrderTimerMap = {}; - const pending: OrderPendingMap = {}; - const log = vi.fn(); - await placeMarketOrder( - adapter, - "BTCUSDT", - [], - locks, - timers, - pending, - "SELL", - 1, - log, - true - ); + const { ctx, adapter, pending } = createContext(); + await placeMarketOrder(ctx, { openOrders: [], side: "SELL", amount: 1, reduceOnly: true }); expect(adapter.createOrder).toHaveBeenCalled(); expect(pending.MARKET).toBe(String(baseOrder.orderId)); }); it("places stop loss order only when valid", async () => { - const adapter = createMockExchange(); - const locks: OrderLockMap = {}; - const timers: OrderTimerMap = {}; - const pending: OrderPendingMap = {}; - const log = vi.fn(); - await placeStopLossOrder( - adapter, - "BTCUSDT", - [], - locks, - timers, - pending, - "SELL", - 99, - 1, - 100, - log - ); + const { ctx, adapter, log } = createContext(); + await placeStopLossOrder(ctx, { + openOrders: [], + side: "SELL", + stopPrice: 99, + quantity: 1, + lastPrice: 100, + }); expect(adapter.createOrder).toHaveBeenCalled(); expect(log).toHaveBeenCalledWith("stop", expect.stringContaining("STOP_MARKET")); }); it("places trailing stop order", async () => { - const adapter = createMockExchange(); - const locks: OrderLockMap = {}; - const timers: OrderTimerMap = {}; - const pending: OrderPendingMap = {}; - const log = vi.fn(); - await placeTrailingStopOrder( - adapter, - "BTCUSDT", - [], - locks, - timers, - pending, - "SELL", - 101, - 1, - 0.2, - log - ); + const { ctx, adapter, log } = createContext(); + await placeTrailingStopOrder(ctx, { + openOrders: [], + side: "SELL", + activationPrice: 101, + quantity: 1, + callbackRate: 0.2, + }); expect(adapter.createOrder).toHaveBeenCalled(); expect(log).toHaveBeenCalledWith("order", expect.stringContaining("挂动态止盈单")); }); it("market close cancels open orders before placing close order", async () => { - const adapter = createMockExchange(); - const locks: OrderLockMap = {}; - const timers: OrderTimerMap = {}; - const pending: OrderPendingMap = {}; - const log = vi.fn(); - await marketClose( - adapter, - "BTCUSDT", - [{ ...baseOrder, orderId: 2 }], - locks, - timers, - pending, - "SELL", - 1, - log - ); + const { ctx, adapter, log } = createContext(); + await marketClose(ctx, { + openOrders: [{ ...baseOrder, orderId: 2 }], + side: "SELL", + quantity: 1, + }); expect(adapter.createOrder).toHaveBeenCalled(); expect(log).toHaveBeenCalledWith("close", expect.stringContaining("市价平仓")); });