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; 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( function enforceMarkPriceGuard(
side: "BUY" | "SELL", side: "BUY" | "SELL",
toCheckPrice: number | null | undefined, toCheckPrice: number | null | undefined,
@@ -48,6 +111,13 @@ function enforceMarkPriceGuard(
return true; 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 { export function isOperating(locks: OrderLockMap, type: string): boolean {
return Boolean(locks[type]); return Boolean(locks[type]);
} }
@@ -86,16 +156,12 @@ export function unlockOperating(
} }
export async function deduplicateOrders( export async function deduplicateOrders(
adapter: ExchangeAdapter, ctx: OrderContext,
symbol: string,
openOrders: Order[], openOrders: Order[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
type: string, type: string,
side: string, side: string
log: LogHandler
): Promise<void> { ): Promise<void> {
const { adapter, symbol, locks, timers, pendings, log } = ctx;
// Treat STOP orders on some exchanges (e.g., Lighter) as LIMIT with stopPrice populated. // Treat STOP orders on some exchanges (e.g., Lighter) as LIMIT with stopPrice populated.
const sameTypeOrders = openOrders.filter((o) => { const sameTypeOrders = openOrders.filter((o) => {
const normalizedType = String(o.type).toUpperCase(); 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( export async function placeOrder(
adapter: ExchangeAdapter, ctx: OrderContext,
symbol: string, request: LimitOrderRequest
openOrders: Order[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
side: "BUY" | "SELL",
price: string, // 改为字符串价格
amount: number,
log: LogHandler,
reduceOnly = false,
guard?: OrderGuardOptions,
opts?: PlaceOrderOptions
): Promise<Order | undefined> { ): Promise<Order | undefined> {
const { locks, timers, pendings, log } = ctx;
const { side, openOrders, guard, reduceOnly = false } = request;
const type = "LIMIT"; const type = "LIMIT";
if (isOperating(locks, type)) return; if (isOperating(locks, type)) return;
const priceNum = Number(price); const priceNum = Number(request.price);
if (!enforceMarkPriceGuard(side, priceNum, guard, log, "限价单")) return; if (!enforceMarkPriceGuard(side, priceNum, guard, log, "限价单")) return;
const qtyStep = opts?.qtyStep ?? 0.001; const quantity = normalizeQuantity(request.amount, request.qtyStep ?? DEFAULT_QTY_STEP);
const rawQuantity = Math.abs(amount);
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
if (quantity <= 0) { if (quantity <= 0) {
log("error", "限价单数量无效,跳过下单"); log("error", "限价单数量无效,跳过下单");
return; return;
} }
if (!opts?.skipDedupe) { if (!request.skipDedupe) {
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); await deduplicateOrders(ctx, openOrders, type, side);
} }
lockOperating(locks, timers, pendings, type, log); lockOperating(locks, timers, pendings, type, log);
try { try {
const closePosition = reduceOnly ? true : undefined; const closePosition = reduceOnly ? true : undefined;
const order = await routeLimitOrder({ const order = await routeLimitOrder({
adapter, adapter: ctx.adapter,
symbol, symbol: ctx.symbol,
side, side,
quantity, quantity,
price: priceNum, price: priceNum,
timeInForce: reduceOnly ? "GTC" : "GTX", timeInForce: reduceOnly ? "GTC" : "GTX",
reduceOnly: reduceOnly ? true : undefined, reduceOnly: reduceOnly ? true : undefined,
closePosition, closePosition,
slPrice: opts?.slPrice, slPrice: request.slPrice,
tpPrice: opts?.tpPrice, tpPrice: request.tpPrice,
clientOrderId: opts?.clientOrderId, clientOrderId: request.clientOrderId,
}); });
pendings[type] = String(order.orderId); 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; return order;
} catch (err) { } catch (err) {
unlockOperating(locks, timers, pendings, type); unlockOperating(locks, timers, pendings, type);
@@ -197,37 +242,26 @@ export async function placeOrder(
} }
export async function placeMarketOrder( export async function placeMarketOrder(
adapter: ExchangeAdapter, ctx: OrderContext,
symbol: string, request: MarketOrderRequest
openOrders: Order[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
side: "BUY" | "SELL",
amount: number,
log: LogHandler,
reduceOnly = false,
guard?: OrderGuardOptions,
opts?: { qtyStep: number }
): Promise<Order | undefined> { ): Promise<Order | undefined> {
const { locks, timers, pendings, log } = ctx;
const { side, openOrders, guard, reduceOnly = false } = request;
const type = "MARKET"; const type = "MARKET";
if (isOperating(locks, type)) return; if (isOperating(locks, type)) return;
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价单")) return; if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价单")) return;
const qtyStep = opts?.qtyStep ?? 0.001; const quantity = normalizeQuantity(request.amount, request.qtyStep ?? DEFAULT_QTY_STEP);
const rawQuantity = Math.abs(amount);
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
if (quantity <= 0) { if (quantity <= 0) {
log("error", "市价单数量无效,跳过下单"); log("error", "市价单数量无效,跳过下单");
return; return;
} }
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); await deduplicateOrders(ctx, openOrders, type, side);
lockOperating(locks, timers, pendings, type, log); lockOperating(locks, timers, pendings, type, log);
try { try {
const closePosition = reduceOnly ? true : undefined; const closePosition = reduceOnly ? true : undefined;
const order = await routeMarketOrder({ const order = await routeMarketOrder({
adapter, adapter: ctx.adapter,
symbol, symbol: ctx.symbol,
side, side,
quantity, quantity,
reduceOnly: reduceOnly ? true : undefined, reduceOnly: reduceOnly ? true : undefined,
@@ -247,20 +281,11 @@ export async function placeMarketOrder(
} }
export async function placeStopLossOrder( export async function placeStopLossOrder(
adapter: ExchangeAdapter, ctx: OrderContext,
symbol: string, request: StopLossOrderRequest
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 }
): Promise<Order | undefined> { ): Promise<Order | undefined> {
const { locks, timers, pendings, log } = ctx;
const { side, openOrders, guard, stopPrice, lastPrice } = request;
const type = "STOP_MARKET"; const type = "STOP_MARKET";
if (isOperating(locks, type)) return; if (isOperating(locks, type)) return;
if (!enforceMarkPriceGuard(side, stopPrice, guard, log, "止损单")) return; if (!enforceMarkPriceGuard(side, stopPrice, guard, log, "止损单")) return;
@@ -274,24 +299,20 @@ export async function placeStopLossOrder(
return; return;
} }
} }
const priceTick = opts?.priceTick ?? 0.1; const normalizedStop = roundDownToTick(stopPrice, request.priceTick ?? DEFAULT_PRICE_TICK);
const qtyStep = opts?.qtyStep ?? 0.001; const normalizedQty = normalizeQuantity(request.quantity, request.qtyStep ?? DEFAULT_QTY_STEP);
const normalizedStop = roundDownToTick(stopPrice, priceTick);
const rawQuantity = Math.abs(quantity);
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
if (normalizedQty <= 0) { if (normalizedQty <= 0) {
log("error", "止损单数量无效,跳过下单"); log("error", "止损单数量无效,跳过下单");
return; return;
} }
// Avoid forcing price for STOP_MARKET globally; keep this exchange-specific in gateways // 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); lockOperating(locks, timers, pendings, type, log);
try { try {
const order = await routeStopOrder({ const order = await routeStopOrder({
adapter, adapter: ctx.adapter,
symbol, symbol: ctx.symbol,
side, side,
quantity: normalizedQty, quantity: normalizedQty,
stopPrice: normalizedStop, stopPrice: normalizedStop,
@@ -314,20 +335,11 @@ export async function placeStopLossOrder(
} }
export async function placeTrailingStopOrder( export async function placeTrailingStopOrder(
adapter: ExchangeAdapter, ctx: OrderContext,
symbol: string, request: TrailingStopOrderRequest
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 }
): Promise<Order | undefined> { ): Promise<Order | undefined> {
const { adapter, locks, timers, pendings, log } = ctx;
const { side, openOrders, guard, activationPrice, callbackRate } = request;
const type = "TRAILING_STOP_MARKET"; const type = "TRAILING_STOP_MARKET";
if (isOperating(locks, type)) return; if (isOperating(locks, type)) return;
if (!adapter.supportsTrailingStops()) { if (!adapter.supportsTrailingStops()) {
@@ -335,22 +347,18 @@ export async function placeTrailingStopOrder(
return; return;
} }
if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return; if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return;
const priceTick = opts?.priceTick ?? 0.1; const normalizedActivation = roundDownToTick(activationPrice, request.priceTick ?? DEFAULT_PRICE_TICK);
const qtyStep = opts?.qtyStep ?? 0.001; const normalizedQty = normalizeQuantity(request.quantity, request.qtyStep ?? DEFAULT_QTY_STEP);
const normalizedActivation = roundDownToTick(activationPrice, priceTick);
const rawQuantity = Math.abs(quantity);
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
if (normalizedQty <= 0) { if (normalizedQty <= 0) {
log("error", "动态止盈单数量无效,跳过下单"); log("error", "动态止盈单数量无效,跳过下单");
return; return;
} }
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); await deduplicateOrders(ctx, openOrders, type, side);
lockOperating(locks, timers, pendings, type, log); lockOperating(locks, timers, pendings, type, log);
try { try {
const order = await routeTrailingStopOrder({ const order = await routeTrailingStopOrder({
adapter, adapter,
symbol, symbol: ctx.symbol,
side, side,
quantity: normalizedQty, quantity: normalizedQty,
activationPrice: normalizedActivation, activationPrice: normalizedActivation,
@@ -374,28 +382,18 @@ export async function placeTrailingStopOrder(
} }
} }
export async function marketClose( export async function marketClose(ctx: OrderContext, request: MarketCloseRequest): Promise<void> {
adapter: ExchangeAdapter, const { locks, timers, pendings, log } = ctx;
symbol: string, const { side, openOrders, guard, qtyStep } = request;
openOrders: Order[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
side: "BUY" | "SELL",
quantity: number,
log: LogHandler,
guard?: OrderGuardOptions,
opts?: { qtyStep: number }
): Promise<void> {
const type = "MARKET"; const type = "MARKET";
if (isOperating(locks, type)) return; if (isOperating(locks, type)) return;
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return; if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return;
const qtyStep = opts?.qtyStep; const rawQuantity = Math.abs(request.quantity);
const rawQuantity = Math.abs(quantity); let normalizedQty = qtyStep != null ? normalizeQuantity(rawQuantity, qtyStep) : rawQuantity;
const normalizedQtyRaw = qtyStep != null ? roundQtyDownToStep(rawQuantity, qtyStep) : rawQuantity;
let normalizedQty = normalizedQtyRaw > 0 ? normalizedQtyRaw : rawQuantity;
if (qtyStep != null) { 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); const epsilon = Math.max(qtyStep * 1e-4, 1e-10);
if (Math.abs(rawQuantity - normalizedQty) <= epsilon) { if (Math.abs(rawQuantity - normalizedQty) <= epsilon) {
normalizedQty = rawQuantity; normalizedQty = rawQuantity;
@@ -406,12 +404,12 @@ export async function marketClose(
return; return;
} }
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); await deduplicateOrders(ctx, openOrders, type, side);
lockOperating(locks, timers, pendings, type, log); lockOperating(locks, timers, pendings, type, log);
try { try {
const order = await routeCloseOrder({ const order = await routeCloseOrder({
adapter, adapter: ctx.adapter,
symbol, symbol: ctx.symbol,
side, side,
quantity: normalizedQty, quantity: normalizedQty,
reduceOnly: true, reduceOnly: true,
+43 -78
View File
@@ -10,11 +10,13 @@ import {
placeOrder, placeOrder,
placeStopLossOrder, placeStopLossOrder,
unlockOperating, unlockOperating,
type OrderContext,
type OrderLockMap, type OrderLockMap,
type OrderPendingMap, type OrderPendingMap,
type OrderTimerMap, type OrderTimerMap,
} from "../core/order-coordinator"; } from "../core/order-coordinator";
import { StrategyEventEmitter } from "./common/event-emitter"; import { StrategyEventEmitter } from "./common/event-emitter";
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
import { safeSubscribe, type LogHandler } from "./common/subscriptions"; import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { clearGridState, loadGridState, saveGridState } from "./common/grid-storage"; import { clearGridState, loadGridState, saveGridState } from "./common/grid-storage";
import { import {
@@ -170,7 +172,7 @@ export class GridEngine {
private savePending = false; private savePending = false;
private uncoveredQty = 0; private uncoveredQty = 0;
private desiredOrders: DesiredGridOrder[] = []; private desiredOrders: DesiredGridOrder[] = [];
private precisionSync: Promise<void> | null = null; private readonly precision: PrecisionSyncer;
constructor( constructor(
private readonly config: GridConfig, private readonly config: GridConfig,
@@ -187,11 +189,25 @@ export class GridEngine {
this.stopReason = "配置无效,已暂停网格"; this.stopReason = "配置无效,已暂停网格";
this.log("error", 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.bootstrap();
this.setupConnectionProtection(); 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 { start(): void {
if (this.timer || !this.running) { if (this.timer || !this.running) {
if (!this.timer && !this.running) { if (!this.timer && !this.running) {
@@ -209,6 +225,7 @@ export class GridEngine {
clearInterval(this.timer); clearInterval(this.timer);
this.timer = null; this.timer = null;
} }
this.precision.stop();
} }
on(event: GridEvent, listener: GridListener): void { on(event: GridEvent, listener: GridListener): void {
@@ -277,37 +294,6 @@ export class GridEngine {
// Precision sync // 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 // Feed subscriptions / connection events
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -744,26 +730,16 @@ export class GridEngine {
const ordersVersionBeforePlace = this.ordersVersion; const ordersVersionBeforePlace = this.ordersVersion;
try { try {
this.lastLimitAttemptAt = now; this.lastLimitAttemptAt = now;
placed = await placeOrder( placed = await placeOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: action.side,
this.openOrders, price: action.price,
this.locks, amount: action.qty,
this.timers, reduceOnly: isEntry ? false : this.config.useReduceOnlyForExit,
this.pendings,
action.side,
action.price,
action.qty,
this.log,
isEntry ? false : this.config.useReduceOnlyForExit,
undefined,
{
priceTick: this.config.priceTick,
qtyStep: this.config.qtyStep, qtyStep: this.config.qtyStep,
skipDedupe: true, skipDedupe: true,
clientOrderId, clientOrderId,
} });
);
} catch (error) { } catch (error) {
this.log("error", `挂单失败 (${action.side} @ ${action.price}): ${extractMessage(error)}`); this.log("error", `挂单失败 (${action.side} @ ${action.price}): ${extractMessage(error)}`);
} }
@@ -829,23 +805,17 @@ export class GridEngine {
} }
} }
try { try {
await marketClose( await marketClose(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, quantity: qty,
this.locks, guard: {
this.timers,
this.pendings,
side,
qty,
this.log,
{
markPrice: mark, markPrice: mark,
expectedPrice: Number.isFinite(closeSidePrice) ? closeSidePrice : null, expectedPrice: Number.isFinite(closeSidePrice) ? closeSidePrice : null,
maxPct: limitPct > 0 ? limitPct : undefined, maxPct: limitPct > 0 ? limitPct : undefined,
}, },
{ qtyStep: this.config.qtyStep } qtyStep: this.config.qtyStep
); });
this.log("close", `市价平仓 ${side} ${qty} (${reason})`); this.log("close", `市价平仓 ${side} ${qty} (${reason})`);
return true; return true;
} catch (error) { } catch (error) {
@@ -971,21 +941,16 @@ export class GridEngine {
const lastPrice = Number(this.tickerSnapshot?.lastPrice); const lastPrice = Number(this.tickerSnapshot?.lastPrice);
try { try {
const placed = await placeStopLossOrder( const placed = await placeStopLossOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: desired.side,
this.openOrders, stopPrice: desired.stopPrice,
this.locks, quantity: Math.abs(this.position.positionAmt),
this.timers, lastPrice: Number.isFinite(lastPrice) ? lastPrice : price,
this.pendings, guard: undefined,
desired.side, priceTick: this.config.priceTick,
desired.stopPrice, qtyStep: this.config.qtyStep
Math.abs(this.position.positionAmt), });
Number.isFinite(lastPrice) ? lastPrice : price,
this.log,
undefined,
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
if (placed?.orderId != null) { if (placed?.orderId != null) {
state.exchangeStop = { state.exchangeStop = {
orderId: String(placed.orderId), orderId: String(placed.orderId),
+54 -61
View File
@@ -15,7 +15,7 @@ import {
placeTrailingStopOrder, placeTrailingStopOrder,
unlockOperating, unlockOperating,
} from "../core/order-coordinator"; } 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 { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
import { extractMessage, isUnknownOrderError } from "../utils/errors"; import { extractMessage, isUnknownOrderError } from "../utils/errors";
import { formatPriceToString } from "../utils/math"; import { formatPriceToString } from "../utils/math";
@@ -76,6 +76,19 @@ export class GuardianEngine {
this.bootstrap(); 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 { start(): void {
if (this.timer) return; if (this.timer) return;
this.timer = setInterval(() => { this.timer = setInterval(() => {
@@ -390,24 +403,19 @@ export class GuardianEngine {
if (quantity <= minQty) { if (quantity <= minQty) {
return; return;
} }
await placeStopLossOrder( await placeStopLossOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, stopPrice: stopPrice,
this.locks, quantity: quantity,
this.timers, lastPrice: lastPrice,
this.pending, guard: {
side,
stopPrice,
quantity,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct, 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.side = side;
this.lastStopAttempt.price = stopPrice; this.lastStopAttempt.price = stopPrice;
this.lastStopAttempt.at = now; this.lastStopAttempt.at = now;
@@ -449,24 +457,19 @@ export class GuardianEngine {
if (quantity <= minQty) { if (quantity <= minQty) {
return; return;
} }
const order = await placeStopLossOrder( const order = await placeStopLossOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, stopPrice: nextStopPrice,
this.locks, quantity: quantity,
this.timers, lastPrice: lastPrice,
this.pending, guard: {
side,
nextStopPrice,
quantity,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } priceTick: this.config.priceTick,
); qtyStep: this.config.qtyStep
});
if (order) { if (order) {
this.tradeLog.push( this.tradeLog.push(
"stop", "stop",
@@ -484,24 +487,19 @@ export class GuardianEngine {
if (quantity <= minQty) { if (quantity <= minQty) {
return; return;
} }
const restored = await placeStopLossOrder( const restored = await placeStopLossOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, stopPrice: Number.isFinite(existingStopPrice) ? existingStopPrice : nextStopPrice,
this.locks, quantity: quantity,
this.timers, lastPrice: lastPrice,
this.pending, guard: {
side,
Number.isFinite(existingStopPrice) ? existingStopPrice : nextStopPrice,
quantity,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct, 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)) { if (restored && Number.isFinite(existingStopPrice)) {
this.tradeLog.push( this.tradeLog.push(
"order", "order",
@@ -525,24 +523,19 @@ export class GuardianEngine {
return; return;
} }
try { try {
await placeTrailingStopOrder( await placeTrailingStopOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, activationPrice: activationPrice,
this.locks, quantity: quantity,
this.timers, callbackRate: this.config.trailingCallbackRate,
this.pending, guard: {
side,
activationPrice,
quantity,
this.config.trailingCallbackRate,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice, markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } priceTick: this.config.priceTick,
); qtyStep: this.config.qtyStep
});
} catch (err) { } catch (err) {
this.tradeLog.push("error", t("log.guardian.trailingFail", { error: String(err) })); this.tradeLog.push("error", t("log.guardian.trailingFail", { error: String(err) }));
} }
+51 -71
View File
@@ -21,7 +21,7 @@ import {
placeOrder, placeOrder,
unlockOperating, unlockOperating,
} from "../core/order-coordinator"; } 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 type { MakerEngineSnapshot } from "./maker-engine";
import { makeOrderPlan } from "../core/lib/order-plan"; import { makeOrderPlan } from "../core/lib/order-plan";
import { safeCancelOrder } from "../core/lib/orders"; import { safeCancelOrder } from "../core/lib/orders";
@@ -151,6 +151,19 @@ export class LiquidityMakerEngine {
this.bootstrap(); 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 { start(): void {
if (this.timer) return; if (this.timer) return;
this.timer = setInterval(() => { this.timer = setInterval(() => {
@@ -698,17 +711,11 @@ export class LiquidityMakerEngine {
const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null; const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null;
const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null; const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null;
try { try {
await marketClose( await marketClose(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, quantity: absPosition,
this.locks, guard: {
this.timers,
this.pending,
side,
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
expectedPrice: expectedPrice:
side === "SELL" side === "SELL"
@@ -716,8 +723,8 @@ export class LiquidityMakerEngine {
: (closeBidPrice != null ? Number(closeBidPrice) : null), : (closeBidPrice != null ? Number(closeBidPrice) : null),
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.precision.qtyStep } qtyStep: this.precision.qtyStep
); });
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "限频强制平仓时订单已不存在"); this.tradeLog.push("order", "限频强制平仓时订单已不存在");
@@ -886,27 +893,18 @@ export class LiquidityMakerEngine {
} }
try { try {
const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly; const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly;
await placeOrder( await placeOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: target.side,
this.openOrders, price: target.price,
this.locks, amount: target.amount,
this.timers, reduceOnly: reduceOnlyFlag,
this.pending, guard: {
target.side,
target.price, // 已经是字符串价格
target.amount,
(type, detail) => this.tradeLog.push(type, detail),
reduceOnlyFlag,
{
markPrice: this.getPositionSnapshot().markPrice, markPrice: this.getPositionSnapshot().markPrice,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.precision.qtyStep
priceTick: this.precision.priceTick, });
qtyStep: this.precision.qtyStep,
}
);
// Record last placed entry order timing and price // Record last placed entry order timing and price
if (!target.reduceOnly) { if (!target.reduceOnly) {
this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() }; 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.exchange.cancelAllOrders({ symbol: this.config.symbol }).catch(() => {});
await this.flushOrders(); await this.flushOrders();
await marketClose( await marketClose(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: "SELL",
this.openOrders, quantity: absPosition,
this.locks, guard: {
this.timers,
this.pending,
"SELL",
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
expectedPrice: bidPrice || null, expectedPrice: bidPrice || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.precision.qtyStep } qtyStep: this.precision.qtyStep
); });
} catch (error) { } catch (error) {
if (isRateLimitError(error)) throw error; if (isRateLimitError(error)) throw error;
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
@@ -1005,23 +997,17 @@ export class LiquidityMakerEngine {
); );
try { try {
await this.flushOrders(); await this.flushOrders();
await marketClose( await marketClose(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: position.positionAmt > 0 ? "SELL" : "BUY",
this.openOrders, quantity: absPosition,
this.locks, guard: {
this.timers,
this.pending,
position.positionAmt > 0 ? "SELL" : "BUY",
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null, expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.precision.qtyStep } qtyStep: this.precision.qtyStep
); });
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "止损平仓时订单已不存在"); this.tradeLog.push("order", "止损平仓时订单已不存在");
@@ -1253,17 +1239,11 @@ export class LiquidityMakerEngine {
if (absQty < EPS) return false; if (absQty < EPS) return false;
const { topBid, topAsk } = getTopPrices(this.depthSnapshot); const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
try { try {
await marketClose( await marketClose(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: target.side,
this.openOrders, quantity: absQty,
this.locks, guard: {
this.timers,
this.pending,
target.side,
absQty,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
expectedPrice: expectedPrice:
target.side === "SELL" target.side === "SELL"
@@ -1271,8 +1251,8 @@ export class LiquidityMakerEngine {
: (topAsk != null ? Number(topAsk) : null), : (topAsk != null ? Number(topAsk) : null),
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.precision.qtyStep } qtyStep: this.precision.qtyStep
); });
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`); this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
return true; return true;
} catch (closeError) { } catch (closeError) {
+30 -32
View File
@@ -20,7 +20,7 @@ import {
placeOrder, placeOrder,
unlockOperating, unlockOperating,
} from "../core/order-coordinator"; } 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 { makeOrderPlan } from "../core/lib/order-plan";
import { safeCancelOrder } from "../core/lib/orders"; import { safeCancelOrder } from "../core/lib/orders";
import { RateLimitController } from "../core/lib/rate-limit"; import { RateLimitController } from "../core/lib/rate-limit";
@@ -127,6 +127,19 @@ export class MakerEngine {
this.bootstrap(); 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 { start(): void {
if (this.timer) return; if (this.timer) return;
this.timer = setInterval(() => { this.timer = setInterval(() => {
@@ -439,27 +452,18 @@ export class MakerEngine {
if (!target) continue; if (!target) continue;
if (target.amount < EPS) continue; if (target.amount < EPS) continue;
try { try {
await placeOrder( await placeOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: target.side,
this.openOrders, price: target.price,
this.locks, amount: target.amount,
this.timers, reduceOnly: target.reduceOnly,
this.pending, guard: {
target.side,
target.price, // 已经是字符串价格
target.amount,
(type, detail) => this.tradeLog.push(type, detail),
target.reduceOnly,
{
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice, markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.precision.qtyStep
priceTick: this.precision.priceTick, });
qtyStep: this.precision.qtyStep,
}
);
} catch (error) { } catch (error) {
if (isInsufficientBalanceError(error)) { if (isInsufficientBalanceError(error)) {
this.registerInsufficientBalance(error); this.registerInsufficientBalance(error);
@@ -507,23 +511,17 @@ export class MakerEngine {
); );
try { try {
await this.flushOrders(); await this.flushOrders();
await marketClose( await marketClose(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: position.positionAmt > 0 ? "SELL" : "BUY",
this.openOrders, quantity: absPosition,
this.locks, guard: {
this.timers,
this.pending,
position.positionAmt > 0 ? "SELL" : "BUY",
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
expectedPrice: Number(closeSidePrice) || null, expectedPrice: Number(closeSidePrice) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.precision.qtyStep } qtyStep: this.precision.qtyStep
); });
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
this.tradeLog.push("order", t("log.maker.stopOrderMissing")); this.tradeLog.push("order", t("log.maker.stopOrderMissing"));
+30 -32
View File
@@ -19,7 +19,7 @@ import {
placeOrder, placeOrder,
unlockOperating, unlockOperating,
} from "../core/order-coordinator"; } 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 { makeOrderPlan } from "../core/lib/order-plan";
import { safeCancelOrder } from "../core/lib/orders"; import { safeCancelOrder } from "../core/lib/orders";
import { RateLimitController } from "../core/lib/rate-limit"; import { RateLimitController } from "../core/lib/rate-limit";
@@ -244,6 +244,19 @@ export class MakerPointsEngine {
this.bootstrap(); 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 { start(): void {
if (this.timer) return; if (this.timer) return;
// 初始化数据时间戳 // 初始化数据时间戳
@@ -1046,26 +1059,17 @@ export class MakerPointsEngine {
: target.side === "BUY" : target.side === "BUY"
? priceNum - 1 ? priceNum - 1
: priceNum + 1; : priceNum + 1;
await placeOrder( await placeOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: target.side,
this.openOrders, price: target.price,
this.locks, amount: target.amount,
this.timers, reduceOnly: target.reduceOnly,
this.pending, guard: undefined,
target.side,
target.price,
target.amount,
(type, detail) => this.tradeLog.push(type, detail),
target.reduceOnly,
undefined,
{
priceTick: this.precision.priceTick,
qtyStep: this.precision.qtyStep, qtyStep: this.precision.qtyStep,
skipDedupe: true, skipDedupe: true,
slPrice, slPrice
} });
);
} catch (error) { } catch (error) {
if (isInsufficientBalanceError(error)) { if (isInsufficientBalanceError(error)) {
this.registerInsufficientBalance(error); this.registerInsufficientBalance(error);
@@ -1231,19 +1235,13 @@ export class MakerPointsEngine {
await this.flushOrders(); await this.flushOrders();
// 执行市价平仓 // 执行市价平仓
await marketClose( await marketClose(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, quantity: currentAbsPosition,
this.locks, guard: undefined,
this.timers, qtyStep: this.precision.qtyStep
this.pending, });
side,
currentAbsPosition,
(type, detail) => this.tradeLog.push(type, detail),
undefined,
{ qtyStep: this.precision.qtyStep }
);
// 等待一小段时间让账户数据更新 // 等待一小段时间让账户数据更新
await this.sleep(STOP_LOSS_RETRY_INTERVAL_MS); await this.sleep(STOP_LOSS_RETRY_INTERVAL_MS);
+58 -84
View File
@@ -22,7 +22,7 @@ import {
placeOrder, placeOrder,
unlockOperating, unlockOperating,
} from "../core/order-coordinator"; } 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 type { MakerEngineSnapshot } from "./maker-engine";
import { makeOrderPlan } from "../core/lib/order-plan"; import { makeOrderPlan } from "../core/lib/order-plan";
import { safeCancelOrder } from "../core/lib/orders"; import { safeCancelOrder } from "../core/lib/orders";
@@ -141,6 +141,19 @@ export class OffsetMakerEngine {
this.bootstrap(); 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 { start(): void {
if (this.timer) return; if (this.timer) return;
this.timer = setInterval(() => { this.timer = setInterval(() => {
@@ -551,17 +564,11 @@ export class OffsetMakerEngine {
const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null; const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null;
const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null; const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null;
try { try {
await marketClose( await marketClose(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, quantity: absPosition,
this.locks, guard: {
this.timers,
this.pending,
side,
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
expectedPrice: expectedPrice:
side === "SELL" side === "SELL"
@@ -569,8 +576,8 @@ export class OffsetMakerEngine {
: (closeBidPrice != null ? Number(closeBidPrice) : null), : (closeBidPrice != null ? Number(closeBidPrice) : null),
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.precision.qtyStep } qtyStep: this.precision.qtyStep
); });
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "限频强制平仓时订单已不存在"); this.tradeLog.push("order", "限频强制平仓时订单已不存在");
@@ -644,23 +651,17 @@ export class OffsetMakerEngine {
); );
try { try {
await this.flushOrders(); await this.flushOrders();
await marketClose( await marketClose(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, quantity: absPosition,
this.locks, guard: {
this.timers,
this.pending,
side,
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
expectedPrice: Number(closeSidePrice) || null, expectedPrice: Number(closeSidePrice) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.precision.qtyStep } qtyStep: this.precision.qtyStep
); });
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "深度不平衡平仓时订单已不存在"); this.tradeLog.push("order", "深度不平衡平仓时订单已不存在");
@@ -747,27 +748,18 @@ export class OffsetMakerEngine {
} }
try { try {
const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly; const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly;
await placeOrder( await placeOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: target.side,
this.openOrders, price: target.price,
this.locks, amount: target.amount,
this.timers, reduceOnly: reduceOnlyFlag,
this.pending, guard: {
target.side,
target.price, // 已经是字符串价格
target.amount,
(type, detail) => this.tradeLog.push(type, detail),
reduceOnlyFlag,
{
markPrice: this.getPositionSnapshot().markPrice, markPrice: this.getPositionSnapshot().markPrice,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.precision.qtyStep
priceTick: this.precision.priceTick, });
qtyStep: this.precision.qtyStep,
}
);
// Record last placed entry order timing and price // Record last placed entry order timing and price
if (!target.reduceOnly) { if (!target.reduceOnly) {
this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() }; 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.exchange.cancelAllOrders({ symbol: this.config.symbol }).catch(() => {});
await this.flushOrders(); await this.flushOrders();
await marketClose( await marketClose(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: "SELL",
this.openOrders, quantity: absPosition,
this.locks, guard: {
this.timers,
this.pending,
"SELL",
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
expectedPrice: bidPrice || null, expectedPrice: bidPrice || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.precision.qtyStep } qtyStep: this.precision.qtyStep
); });
} catch (error) { } catch (error) {
if (isRateLimitError(error)) throw error; if (isRateLimitError(error)) throw error;
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
@@ -866,23 +852,17 @@ export class OffsetMakerEngine {
); );
try { try {
await this.flushOrders(); await this.flushOrders();
await marketClose( await marketClose(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: position.positionAmt > 0 ? "SELL" : "BUY",
this.openOrders, quantity: absPosition,
this.locks, guard: {
this.timers,
this.pending,
position.positionAmt > 0 ? "SELL" : "BUY",
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null, expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.precision.qtyStep } qtyStep: this.precision.qtyStep
); });
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "止损平仓时订单已不存在"); this.tradeLog.push("order", "止损平仓时订单已不存在");
@@ -1123,17 +1103,11 @@ export class OffsetMakerEngine {
if (absQty < EPS) return false; if (absQty < EPS) return false;
const { topBid, topAsk } = getTopPrices(this.depthSnapshot); const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
try { try {
await marketClose( await marketClose(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: target.side,
this.openOrders, quantity: absQty,
this.locks, guard: {
this.timers,
this.pending,
target.side,
absQty,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
expectedPrice: expectedPrice:
target.side === "SELL" target.side === "SELL"
@@ -1141,8 +1115,8 @@ export class OffsetMakerEngine {
: (topAsk != null ? Number(topAsk) : null), : (topAsk != null ? Number(topAsk) : null),
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.precision.qtyStep } qtyStep: this.precision.qtyStep
); });
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`); this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
return true; return true;
} catch (closeError) { } 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 type { AccountSnapshot, Depth, Order, Ticker } from "../exchanges/types";
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
import { marketClose, placeMarketOrder, placeStopLossOrder, unlockOperating } from "../core/order-coordinator"; 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 { extractMessage, isRateLimitError, isUnknownOrderError } from "../utils/errors";
import { getPosition, type PositionSnapshot } from "../utils/strategy"; import { getPosition, type PositionSnapshot } from "../utils/strategy";
import { computePositionPnl } from "../utils/pnl"; import { computePositionPnl } from "../utils/pnl";
@@ -136,6 +136,19 @@ export class SwingEngine {
this.bootstrap(); 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 { start(): void {
if (this.timer) return; if (this.timer) return;
this.timer = setInterval(() => { this.timer = setInterval(() => {
@@ -348,24 +361,18 @@ export class SwingEngine {
if (Math.abs(position.positionAmt) > EPS) { if (Math.abs(position.positionAmt) > EPS) {
return; return;
} }
await placeMarketOrder( await placeMarketOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, amount: this.config.tradeAmount,
this.locks, reduceOnly: false,
this.timers, guard: {
this.pending,
side,
this.config.tradeAmount,
(type, detail) => this.tradeLog.push(type, detail),
false,
{
markPrice: position.markPrice, markPrice: position.markPrice,
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null, expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.config.qtyStep } qtyStep: this.config.qtyStep
); });
this.tradeLog.push("open", `${reason}: ${side} (market)`); this.tradeLog.push("open", `${reason}: ${side} (market)`);
} catch (err) { } catch (err) {
this.tradeLog.push("error", `Open failed: ${extractMessage(err)}`); this.tradeLog.push("error", `Open failed: ${extractMessage(err)}`);
@@ -380,23 +387,17 @@ export class SwingEngine {
side === "SELL" side === "SELL"
? Number(this.depthSnapshot?.bids?.[0]?.[0]) ? Number(this.depthSnapshot?.bids?.[0]?.[0])
: Number(this.depthSnapshot?.asks?.[0]?.[0]); : Number(this.depthSnapshot?.asks?.[0]?.[0]);
await marketClose( await marketClose(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, quantity: Math.abs(position.positionAmt),
this.locks, guard: {
this.timers,
this.pending,
side,
Math.abs(position.positionAmt),
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
expectedPrice: Number.isFinite(expected) ? expected : Number(this.tickerSnapshot?.lastPrice) || null, expectedPrice: Number.isFinite(expected) ? expected : Number(this.tickerSnapshot?.lastPrice) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.config.qtyStep } qtyStep: this.config.qtyStep
); });
this.tradeLog.push("close", `${reason}: ${side} (market close)`); this.tradeLog.push("close", `${reason}: ${side} (market close)`);
} catch (err) { } catch (err) {
if (isUnknownOrderError(err)) { if (isUnknownOrderError(err)) {
@@ -459,24 +460,19 @@ export class SwingEngine {
try { try {
const qty = Math.abs(position.positionAmt); const qty = Math.abs(position.positionAmt);
await placeStopLossOrder( await placeStopLossOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: stopSide,
this.openOrders, stopPrice: stopPrice,
this.locks, quantity: qty,
this.timers, lastPrice: lastPrice,
this.pending, guard: {
stopSide,
stopPrice,
qty,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct, 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() }; this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() };
} catch (err) { } catch (err) {
this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() }; this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() };
+69 -88
View File
@@ -25,7 +25,7 @@ import {
placeTrailingStopOrder, placeTrailingStopOrder,
unlockOperating, unlockOperating,
} from "../core/order-coordinator"; } 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 { extractMessage, isUnknownOrderError } from "../utils/errors";
import { formatPriceToString } from "../utils/math"; import { formatPriceToString } from "../utils/math";
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
@@ -139,6 +139,19 @@ export class TrendEngine {
this.bootstrap(); 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 { start(): void {
if (this.timer) return; if (this.timer) return;
this.timer = setInterval(() => { this.timer = setInterval(() => {
@@ -490,24 +503,18 @@ export class TrendEngine {
private async submitMarketOrder(side: "BUY" | "SELL", price: number, reason: string): Promise<void> { private async submitMarketOrder(side: "BUY" | "SELL", price: number, reason: string): Promise<void> {
try { try {
await placeMarketOrder( await placeMarketOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, amount: this.config.tradeAmount,
this.locks, reduceOnly: false,
this.timers, guard: {
this.pending,
side,
this.config.tradeAmount,
(type, detail) => this.tradeLog.push(type, detail),
false,
{
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice, markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null, expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.config.qtyStep } qtyStep: this.config.qtyStep
); });
this.tradeLog.push("open", `${reason}: ${side} @ ${price}`); this.tradeLog.push("open", `${reason}: ${side} @ ${price}`);
this.lastOpenPlan = { side, price }; this.lastOpenPlan = { side, price };
} catch (err) { } catch (err) {
@@ -748,17 +755,11 @@ export class TrendEngine {
return { closed: false, pnl }; return { closed: false, pnl };
} }
} }
await marketClose( await marketClose(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: direction === "long" ? "SELL" : "BUY",
this.openOrders, quantity: Math.abs(position.positionAmt),
this.locks, guard: {
this.timers,
this.pending,
direction === "long" ? "SELL" : "BUY",
Math.abs(position.positionAmt),
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice, markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
expectedPrice: Number( expectedPrice: Number(
direction === "long" direction === "long"
@@ -767,8 +768,8 @@ export class TrendEngine {
) || null, ) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.config.qtyStep } qtyStep: this.config.qtyStep
); });
result.closed = true; result.closed = true;
this.tradeLog.push("close", t("log.trend.stopClose", { side: direction === "long" ? "SELL" : "BUY" })); this.tradeLog.push("close", t("log.trend.stopClose", { side: direction === "long" ? "SELL" : "BUY" }));
// 记录止损时间以便短期内抑制再次入场 // 记录止损时间以便短期内抑制再次入场
@@ -811,24 +812,19 @@ export class TrendEngine {
if (quantity <= minQty) { if (quantity <= minQty) {
return; return;
} }
await placeStopLossOrder( await placeStopLossOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, stopPrice: stopPrice,
this.locks, quantity: quantity,
this.timers, lastPrice: lastPrice,
this.pending, guard: {
side,
stopPrice,
quantity,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct, 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() }; this.lastStopAttempt = { side, price: stopPrice, at: Date.now() };
} catch (err) { } catch (err) {
this.tradeLog.push("error", t("log.trend.placeStopFail", { error: String(err) })); this.tradeLog.push("error", t("log.trend.placeStopFail", { error: String(err) }));
@@ -871,24 +867,19 @@ export class TrendEngine {
if (quantity <= minQty) { if (quantity <= minQty) {
return; return;
} }
const order = await placeStopLossOrder( const order = await placeStopLossOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, stopPrice: nextStopPrice,
this.locks, quantity: quantity,
this.timers, lastPrice: lastPrice,
this.pending, guard: {
side,
nextStopPrice,
quantity,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice, markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } priceTick: this.config.priceTick,
); qtyStep: this.config.qtyStep
});
if (order) { if (order) {
this.tradeLog.push( this.tradeLog.push(
"stop", "stop",
@@ -914,24 +905,19 @@ export class TrendEngine {
(side === "SELL" && existingStopPrice >= lastPrice) || (side === "SELL" && existingStopPrice >= lastPrice) ||
(side === "BUY" && existingStopPrice <= lastPrice); (side === "BUY" && existingStopPrice <= lastPrice);
if (!restoreInvalid) { if (!restoreInvalid) {
const restored = await placeStopLossOrder( const restored = await placeStopLossOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, stopPrice: existingStopPrice,
this.locks, quantity: quantity,
this.timers, lastPrice: lastPrice,
this.pending, guard: {
side,
existingStopPrice,
quantity,
lastPrice,
(t, d) => this.tradeLog.push(t, d),
{
markPrice: position.markPrice, markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } priceTick: this.config.priceTick,
); qtyStep: this.config.qtyStep
});
if (restored) { if (restored) {
this.tradeLog.push( this.tradeLog.push(
"order", "order",
@@ -959,24 +945,19 @@ export class TrendEngine {
return; return;
} }
try { try {
await placeTrailingStopOrder( await placeTrailingStopOrder(this.orderContext, {
this.exchange, openOrders: this.openOrders,
this.config.symbol, side: side,
this.openOrders, activationPrice: activationPrice,
this.locks, quantity: quantity,
this.timers, callbackRate: this.config.trailingCallbackRate,
this.pending, guard: {
side,
activationPrice,
quantity,
this.config.trailingCallbackRate,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice, markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } priceTick: this.config.priceTick,
); qtyStep: this.config.qtyStep
});
} catch (err) { } catch (err) {
this.tradeLog.push("error", t("log.trend.trailingFail", { error: String(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 { describe, expect, it, vi, beforeEach, afterAll } from "vitest";
import type { ExchangeAdapter } from "../src/exchanges/adapter"; import type { ExchangeAdapter } from "../src/exchanges/adapter";
import type { Order } from "../src/exchanges/types"; 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 { import {
deduplicateOrders, deduplicateOrders,
placeOrder, placeOrder,
@@ -60,130 +65,76 @@ describe("order-coordinator", () => {
process.env.EXCHANGE = originalExchange; 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 adapter = createMockExchange();
const locks: OrderLockMap = {}; const locks: OrderLockMap = {};
const timers: OrderTimerMap = {}; const timers: OrderTimerMap = {};
const pending: OrderPendingMap = {}; const pending: OrderPendingMap = {};
const log = vi.fn(); 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[] = [ const openOrders: Order[] = [
{ ...baseOrder, orderId: 1 }, { ...baseOrder, orderId: 1 },
{ ...baseOrder, orderId: 2 }, { ...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(adapter.cancelOrders).toHaveBeenCalledWith({ symbol: "BTCUSDT", orderIdList: [2] });
expect(log).toHaveBeenCalledWith("order", expect.stringContaining("去重撤销重复")); expect(log).toHaveBeenCalledWith("order", expect.stringContaining("去重撤销重复"));
}); });
it("places limit orders and records pending id", async () => { it("places limit orders and records pending id", async () => {
const adapter = createMockExchange(); const { ctx, adapter, pending } = createContext();
const locks: OrderLockMap = {}; await placeOrder(ctx, { openOrders: [], side: "BUY", price: "100", amount: 1, reduceOnly: false });
const timers: OrderTimerMap = {};
const pending: OrderPendingMap = {};
const log = vi.fn();
await placeOrder(
adapter,
"BTCUSDT",
[],
locks,
timers,
pending,
"BUY",
"100",
1,
log,
false
);
expect(adapter.createOrder).toHaveBeenCalled(); expect(adapter.createOrder).toHaveBeenCalled();
expect(pending.MARKET).toBeUndefined(); expect(pending.MARKET).toBeUndefined();
expect(pending.LIMIT).toBe(String(baseOrder.orderId)); expect(pending.LIMIT).toBe(String(baseOrder.orderId));
}); });
it("places market order and unlocks after completion", async () => { it("places market order and unlocks after completion", async () => {
const adapter = createMockExchange(); const { ctx, adapter, pending } = createContext();
const locks: OrderLockMap = {}; await placeMarketOrder(ctx, { openOrders: [], side: "SELL", amount: 1, reduceOnly: true });
const timers: OrderTimerMap = {};
const pending: OrderPendingMap = {};
const log = vi.fn();
await placeMarketOrder(
adapter,
"BTCUSDT",
[],
locks,
timers,
pending,
"SELL",
1,
log,
true
);
expect(adapter.createOrder).toHaveBeenCalled(); expect(adapter.createOrder).toHaveBeenCalled();
expect(pending.MARKET).toBe(String(baseOrder.orderId)); expect(pending.MARKET).toBe(String(baseOrder.orderId));
}); });
it("places stop loss order only when valid", async () => { it("places stop loss order only when valid", async () => {
const adapter = createMockExchange(); const { ctx, adapter, log } = createContext();
const locks: OrderLockMap = {}; await placeStopLossOrder(ctx, {
const timers: OrderTimerMap = {}; openOrders: [],
const pending: OrderPendingMap = {}; side: "SELL",
const log = vi.fn(); stopPrice: 99,
await placeStopLossOrder( quantity: 1,
adapter, lastPrice: 100,
"BTCUSDT", });
[],
locks,
timers,
pending,
"SELL",
99,
1,
100,
log
);
expect(adapter.createOrder).toHaveBeenCalled(); expect(adapter.createOrder).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("stop", expect.stringContaining("STOP_MARKET")); expect(log).toHaveBeenCalledWith("stop", expect.stringContaining("STOP_MARKET"));
}); });
it("places trailing stop order", async () => { it("places trailing stop order", async () => {
const adapter = createMockExchange(); const { ctx, adapter, log } = createContext();
const locks: OrderLockMap = {}; await placeTrailingStopOrder(ctx, {
const timers: OrderTimerMap = {}; openOrders: [],
const pending: OrderPendingMap = {}; side: "SELL",
const log = vi.fn(); activationPrice: 101,
await placeTrailingStopOrder( quantity: 1,
adapter, callbackRate: 0.2,
"BTCUSDT", });
[],
locks,
timers,
pending,
"SELL",
101,
1,
0.2,
log
);
expect(adapter.createOrder).toHaveBeenCalled(); expect(adapter.createOrder).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("order", expect.stringContaining("挂动态止盈单")); expect(log).toHaveBeenCalledWith("order", expect.stringContaining("挂动态止盈单"));
}); });
it("market close cancels open orders before placing close order", async () => { it("market close cancels open orders before placing close order", async () => {
const adapter = createMockExchange(); const { ctx, adapter, log } = createContext();
const locks: OrderLockMap = {}; await marketClose(ctx, {
const timers: OrderTimerMap = {}; openOrders: [{ ...baseOrder, orderId: 2 }],
const pending: OrderPendingMap = {}; side: "SELL",
const log = vi.fn(); quantity: 1,
await marketClose( });
adapter,
"BTCUSDT",
[{ ...baseOrder, orderId: 2 }],
locks,
timers,
pending,
"SELL",
1,
log
);
expect(adapter.createOrder).toHaveBeenCalled(); expect(adapter.createOrder).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("close", expect.stringContaining("市价平仓")); expect(log).toHaveBeenCalledWith("close", expect.stringContaining("市价平仓"));
}); });