refactor(core): give order functions a parameter object

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.
This commit is contained in:
discountry
2026-07-29 21:17:58 +08:00
parent 6f64dd5a0a
commit 5aecaabb16
10 changed files with 542 additions and 708 deletions
+122 -124
View File
@@ -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<void> {
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<Order | undefined> {
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<Order | undefined> {
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<Order | undefined> {
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<Order | undefined> {
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<void> {
export async function marketClose(ctx: OrderContext, request: MarketCloseRequest): Promise<void> {
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,
+46 -81
View File
@@ -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<void> | 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),
+54 -61
View File
@@ -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) }));
}
+51 -71
View File
@@ -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) {
+30 -32
View File
@@ -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"));
+32 -34
View File
@@ -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);
+58 -84
View File
@@ -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) {
+39 -43
View File
@@ -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() };
+69 -88
View File
@@ -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<void> {
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) }));
}
+41 -90
View File
@@ -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("市价平仓"));
});