更新交易引擎,添加最大平仓滑点百分比配置以优化价格操控保护逻辑,确保在市价平仓时价格与标记价格的偏差在允许范围内。

This commit is contained in:
discountry
2025-09-24 03:49:37 +08:00
parent 9c45458507
commit dc6f9ee72e
6 changed files with 161 additions and 16 deletions
+7
View File
@@ -9,6 +9,7 @@ export interface TradingConfig {
pollIntervalMs: number; pollIntervalMs: number;
maxLogEntries: number; maxLogEntries: number;
klineInterval: string; klineInterval: string;
maxCloseSlippagePct: number;
} }
function parseNumber(value: string | undefined, fallback: number): number { function parseNumber(value: string | undefined, fallback: number): number {
@@ -28,6 +29,7 @@ export const tradingConfig: TradingConfig = {
pollIntervalMs: parseNumber(process.env.POLL_INTERVAL_MS, 500), pollIntervalMs: parseNumber(process.env.POLL_INTERVAL_MS, 500),
maxLogEntries: parseNumber(process.env.MAX_LOG_ENTRIES, 200), maxLogEntries: parseNumber(process.env.MAX_LOG_ENTRIES, 200),
klineInterval: process.env.KLINE_INTERVAL ?? "1m", klineInterval: process.env.KLINE_INTERVAL ?? "1m",
maxCloseSlippagePct: parseNumber(process.env.MAX_CLOSE_SLIPPAGE_PCT, 0.05),
}; };
export interface MakerConfig { export interface MakerConfig {
@@ -39,6 +41,7 @@ export interface MakerConfig {
askOffset: number; askOffset: number;
refreshIntervalMs: number; refreshIntervalMs: number;
maxLogEntries: number; maxLogEntries: number;
maxCloseSlippagePct: number;
} }
export const makerConfig: MakerConfig = { export const makerConfig: MakerConfig = {
@@ -50,4 +53,8 @@ export const makerConfig: MakerConfig = {
askOffset: parseNumber(process.env.MAKER_ASK_OFFSET, 0), askOffset: parseNumber(process.env.MAKER_ASK_OFFSET, 0),
refreshIntervalMs: parseNumber(process.env.MAKER_REFRESH_INTERVAL_MS, 1500), refreshIntervalMs: parseNumber(process.env.MAKER_REFRESH_INTERVAL_MS, 1500),
maxLogEntries: parseNumber(process.env.MAKER_MAX_LOG_ENTRIES, 200), maxLogEntries: parseNumber(process.env.MAKER_MAX_LOG_ENTRIES, 200),
maxCloseSlippagePct: parseNumber(
process.env.MAKER_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT,
0.05
),
}; };
+14 -2
View File
@@ -309,7 +309,11 @@ export class MakerEngine {
target.price, target.price,
target.amount, target.amount,
(type, detail) => this.tradeLog.push(type, detail), (type, detail) => this.tradeLog.push(type, detail),
target.reduceOnly target.reduceOnly,
{
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
maxPct: this.config.maxCloseSlippagePct,
}
); );
} catch (error) { } catch (error) {
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`); this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
@@ -345,6 +349,9 @@ export class MakerEngine {
); );
if (derivedLoss || snapshotLoss) { if (derivedLoss || snapshotLoss) {
// 价格操纵保护:只有平仓方向价格与标记价格在阈值内才允许市价平仓
const closeSideIsSell = position.positionAmt > 0;
const closeSidePrice = closeSideIsSell ? bidPrice : askPrice;
this.tradeLog.push( this.tradeLog.push(
"stop", "stop",
`触发止损,方向=${position.positionAmt > 0 ? "多" : "空"} 当前亏损=${pnl.toFixed(4)} USDT` `触发止损,方向=${position.positionAmt > 0 ? "多" : "空"} 当前亏损=${pnl.toFixed(4)} USDT`
@@ -360,7 +367,12 @@ export class MakerEngine {
this.pending, this.pending,
position.positionAmt > 0 ? "SELL" : "BUY", position.positionAmt > 0 ? "SELL" : "BUY",
absPosition, absPosition,
(type, detail) => this.tradeLog.push(type, detail) (type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice,
expectedPrice: Number(closeSidePrice) || null,
maxPct: this.config.maxCloseSlippagePct,
}
); );
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
+20 -3
View File
@@ -306,6 +306,9 @@ export class OffsetMakerEngine {
if (!longExitRequired && !shortExitRequired) return false; if (!longExitRequired && !shortExitRequired) return false;
const side: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY"; const side: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
const bid = Number(this.depthSnapshot?.bids?.[0]?.[0]);
const ask = Number(this.depthSnapshot?.asks?.[0]?.[0]);
const closeSidePrice = side === "SELL" ? bid : ask;
this.tradeLog.push( this.tradeLog.push(
"stop", "stop",
`深度极端不平衡(${buySum.toFixed(4)} vs ${sellSum.toFixed(4)}), 市价平仓 ${side}` `深度极端不平衡(${buySum.toFixed(4)} vs ${sellSum.toFixed(4)}), 市价平仓 ${side}`
@@ -321,7 +324,12 @@ export class OffsetMakerEngine {
this.pending, this.pending,
side, side,
absPosition, absPosition,
(type, detail) => this.tradeLog.push(type, detail) (type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice,
expectedPrice: Number(closeSidePrice) || null,
maxPct: this.config.maxCloseSlippagePct,
}
); );
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
@@ -396,7 +404,11 @@ export class OffsetMakerEngine {
target.price, target.price,
target.amount, target.amount,
(type, detail) => this.tradeLog.push(type, detail), (type, detail) => this.tradeLog.push(type, detail),
target.reduceOnly target.reduceOnly,
{
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
maxPct: this.config.maxCloseSlippagePct,
}
); );
} catch (error) { } catch (error) {
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`); this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
@@ -447,7 +459,12 @@ export class OffsetMakerEngine {
this.pending, this.pending,
position.positionAmt > 0 ? "SELL" : "BUY", position.positionAmt > 0 ? "SELL" : "BUY",
absPosition, absPosition,
(type, detail) => this.tradeLog.push(type, detail) (type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice,
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
maxPct: this.config.maxCloseSlippagePct,
}
); );
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
+48 -5
View File
@@ -2,12 +2,45 @@ import type { ExchangeAdapter } from "../exchanges/adapter";
import type { AsterOrder, CreateOrderParams } from "../exchanges/types"; import type { AsterOrder, CreateOrderParams } from "../exchanges/types";
import { toPrice1Decimal, toQty3Decimal } from "../utils/math"; import { toPrice1Decimal, toQty3Decimal } from "../utils/math";
import { isUnknownOrderError } from "../utils/errors"; import { isUnknownOrderError } from "../utils/errors";
import { isOrderPriceAllowedByMark } from "../utils/strategy";
export type OrderLockMap = Record<string, boolean>; export type OrderLockMap = Record<string, boolean>;
export type OrderTimerMap = Record<string, ReturnType<typeof setTimeout> | null>; export type OrderTimerMap = Record<string, ReturnType<typeof setTimeout> | null>;
export type OrderPendingMap = Record<string, string | null>; export type OrderPendingMap = Record<string, string | null>;
export type LogHandler = (type: string, detail: string) => void; export type LogHandler = (type: string, detail: string) => void;
type OrderGuardOptions = {
markPrice?: number | null;
expectedPrice?: number | null;
maxPct?: number;
};
function enforceMarkPriceGuard(
side: "BUY" | "SELL",
toCheckPrice: number | null | undefined,
guard: OrderGuardOptions | undefined,
log: LogHandler,
context: string
): boolean {
if (!guard || guard.maxPct == null) return true;
const allowed = isOrderPriceAllowedByMark({
side,
orderPrice: toCheckPrice,
markPrice: guard.markPrice,
maxPct: guard.maxPct,
});
if (!allowed) {
const priceStr = Number.isFinite(Number(toCheckPrice)) ? Number(toCheckPrice).toFixed(2) : String(toCheckPrice);
const markStr = Number.isFinite(Number(guard.markPrice)) ? Number(guard.markPrice).toFixed(2) : String(guard.markPrice);
log(
"info",
`${context} 保护触发:side=${side} price=${priceStr} mark=${markStr} 超过 ${(guard.maxPct! * 100).toFixed(2)}%`
);
return false;
}
return true;
}
export function isOperating(locks: OrderLockMap, type: string): boolean { export function isOperating(locks: OrderLockMap, type: string): boolean {
return Boolean(locks[type]); return Boolean(locks[type]);
} }
@@ -92,10 +125,12 @@ export async function placeOrder(
price: number, price: number,
amount: number, amount: number,
log: LogHandler, log: LogHandler,
reduceOnly = false reduceOnly = false,
guard?: OrderGuardOptions
): Promise<AsterOrder | undefined> { ): Promise<AsterOrder | undefined> {
const type = "LIMIT"; const type = "LIMIT";
if (isOperating(locks, type)) return; if (isOperating(locks, type)) return;
if (!enforceMarkPriceGuard(side, price, guard, log, "限价单")) return;
const params: CreateOrderParams = { const params: CreateOrderParams = {
symbol, symbol,
side, side,
@@ -132,10 +167,12 @@ export async function placeMarketOrder(
side: "BUY" | "SELL", side: "BUY" | "SELL",
amount: number, amount: number,
log: LogHandler, log: LogHandler,
reduceOnly = false reduceOnly = false,
guard?: OrderGuardOptions
): Promise<AsterOrder | undefined> { ): Promise<AsterOrder | undefined> {
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;
const params: CreateOrderParams = { const params: CreateOrderParams = {
symbol, symbol,
side, side,
@@ -171,10 +208,12 @@ export async function placeStopLossOrder(
stopPrice: number, stopPrice: number,
quantity: number, quantity: number,
lastPrice: number | null, lastPrice: number | null,
log: LogHandler log: LogHandler,
guard?: OrderGuardOptions
): Promise<AsterOrder | undefined> { ): Promise<AsterOrder | undefined> {
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 (lastPrice != null) { if (lastPrice != null) {
if (side === "SELL" && stopPrice >= lastPrice) { if (side === "SELL" && stopPrice >= lastPrice) {
log("error", `止损价 ${stopPrice} 高于或等于当前价 ${lastPrice},取消挂单`); log("error", `止损价 ${stopPrice} 高于或等于当前价 ${lastPrice},取消挂单`);
@@ -222,10 +261,12 @@ export async function placeTrailingStopOrder(
activationPrice: number, activationPrice: number,
quantity: number, quantity: number,
callbackRate: number, callbackRate: number,
log: LogHandler log: LogHandler,
guard?: OrderGuardOptions
): Promise<AsterOrder | undefined> { ): Promise<AsterOrder | undefined> {
const type = "TRAILING_STOP_MARKET"; const type = "TRAILING_STOP_MARKET";
if (isOperating(locks, type)) return; if (isOperating(locks, type)) return;
if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return;
const params: CreateOrderParams = { const params: CreateOrderParams = {
symbol, symbol,
side, side,
@@ -265,10 +306,12 @@ export async function marketClose(
pendings: OrderPendingMap, pendings: OrderPendingMap,
side: "BUY" | "SELL", side: "BUY" | "SELL",
quantity: number, quantity: number,
log: LogHandler log: LogHandler,
guard?: OrderGuardOptions
): Promise<void> { ): 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;
const params: CreateOrderParams = { const params: CreateOrderParams = {
symbol, symbol,
side, side,
+44 -4
View File
@@ -286,7 +286,13 @@ export class TrendEngine {
this.pending, this.pending,
side, side,
this.config.tradeAmount, this.config.tradeAmount,
(type, detail) => this.tradeLog.push(type, detail) (type, detail) => this.tradeLog.push(type, detail),
false,
{
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
maxPct: this.config.maxCloseSlippagePct,
}
); );
this.tradeLog.push("open", `${reason}: ${side} @ ${price}`); this.tradeLog.push("open", `${reason}: ${side} @ ${price}`);
this.lastOpenPlan = { side, price }; this.lastOpenPlan = { side, price };
@@ -395,6 +401,23 @@ export class TrendEngine {
} }
} }
} }
// 价格操纵保护:仅当平仓方向价格与标记价格偏离在阈值内才执行市价平仓
const mark = getPosition(this.accountSnapshot, this.config.symbol).markPrice;
const limitPct = this.config.maxCloseSlippagePct;
const sideIsSell = direction === "long";
const depthBid = Number(this.depthSnapshot?.bids?.[0]?.[0]);
const depthAsk = Number(this.depthSnapshot?.asks?.[0]?.[0]);
const closeSidePrice = sideIsSell ? depthBid : depthAsk;
if (mark != null && Number.isFinite(mark) && mark > 0 && Number.isFinite(closeSidePrice)) {
const pctDiff = Math.abs(closeSidePrice - mark) / mark;
if (pctDiff > limitPct) {
this.tradeLog.push(
"info",
`市价平仓保护触发:closePx=${Number(closeSidePrice).toFixed(2)} mark=${mark.toFixed(2)} 偏离 ${(pctDiff * 100).toFixed(2)}% > ${(limitPct * 100).toFixed(2)}%`
);
return { closed: false, pnl };
}
}
await marketClose( await marketClose(
this.exchange, this.exchange,
this.config.symbol, this.config.symbol,
@@ -404,7 +427,16 @@ export class TrendEngine {
this.pending, this.pending,
direction === "long" ? "SELL" : "BUY", direction === "long" ? "SELL" : "BUY",
Math.abs(position.positionAmt), Math.abs(position.positionAmt),
(type, detail) => this.tradeLog.push(type, detail) (type, detail) => this.tradeLog.push(type, detail),
{
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
expectedPrice: Number(
direction === "long"
? this.depthSnapshot?.bids?.[0]?.[0]
: this.depthSnapshot?.asks?.[0]?.[0]
) || null,
maxPct: this.config.maxCloseSlippagePct,
}
); );
this.tradeLog.push("close", `止损平仓: ${direction === "long" ? "SELL" : "BUY"}`); this.tradeLog.push("close", `止损平仓: ${direction === "long" ? "SELL" : "BUY"}`);
} catch (err) { } catch (err) {
@@ -439,7 +471,11 @@ export class TrendEngine {
stopPrice, stopPrice,
quantity, quantity,
lastPrice, lastPrice,
(type, detail) => this.tradeLog.push(type, detail) (type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct,
}
); );
} catch (err) { } catch (err) {
this.tradeLog.push("error", `挂止损单失败: ${String(err)}`); this.tradeLog.push("error", `挂止损单失败: ${String(err)}`);
@@ -484,7 +520,11 @@ export class TrendEngine {
activationPrice, activationPrice,
quantity, quantity,
this.config.trailingCallbackRate, this.config.trailingCallbackRate,
(type, detail) => this.tradeLog.push(type, detail) (type, detail) => this.tradeLog.push(type, detail),
{
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
maxPct: this.config.maxCloseSlippagePct,
}
); );
} catch (err) { } catch (err) {
this.tradeLog.push("error", `挂动态止盈失败: ${String(err)}`); this.tradeLog.push("error", `挂动态止盈失败: ${String(err)}`);
+28 -2
View File
@@ -4,15 +4,16 @@ export interface PositionSnapshot {
positionAmt: number; positionAmt: number;
entryPrice: number; entryPrice: number;
unrealizedProfit: number; unrealizedProfit: number;
markPrice: number | null;
} }
export function getPosition(snapshot: AsterAccountSnapshot | null, symbol: string): PositionSnapshot { export function getPosition(snapshot: AsterAccountSnapshot | null, symbol: string): PositionSnapshot {
if (!snapshot) { if (!snapshot) {
return { positionAmt: 0, entryPrice: 0, unrealizedProfit: 0 }; return { positionAmt: 0, entryPrice: 0, unrealizedProfit: 0, markPrice: null };
} }
const positions = snapshot.positions?.filter((p) => p.symbol === symbol) ?? []; const positions = snapshot.positions?.filter((p) => p.symbol === symbol) ?? [];
if (positions.length === 0) { if (positions.length === 0) {
return { positionAmt: 0, entryPrice: 0, unrealizedProfit: 0 }; return { positionAmt: 0, entryPrice: 0, unrealizedProfit: 0, markPrice: null };
} }
const NON_ZERO_EPS = 1e-8; const NON_ZERO_EPS = 1e-8;
const withExposure = positions.filter((p) => Math.abs(Number(p.positionAmt)) > NON_ZERO_EPS); const withExposure = positions.filter((p) => Math.abs(Number(p.positionAmt)) > NON_ZERO_EPS);
@@ -20,10 +21,13 @@ export function getPosition(snapshot: AsterAccountSnapshot | null, symbol: strin
withExposure.find((p) => p.positionSide === "BOTH") ?? withExposure.find((p) => p.positionSide === "BOTH") ??
withExposure.sort((a, b) => Math.abs(Number(b.positionAmt)) - Math.abs(Number(a.positionAmt)))[0] ?? withExposure.sort((a, b) => Math.abs(Number(b.positionAmt)) - Math.abs(Number(a.positionAmt)))[0] ??
positions[0]; positions[0];
const rawMark = Number(selected?.markPrice);
const markPrice = Number.isFinite(rawMark) && rawMark > 0 ? rawMark : null;
return { return {
positionAmt: Number(selected?.positionAmt) || 0, positionAmt: Number(selected?.positionAmt) || 0,
entryPrice: Number(selected?.entryPrice) || 0, entryPrice: Number(selected?.entryPrice) || 0,
unrealizedProfit: Number(selected?.unrealizedProfit) || 0, unrealizedProfit: Number(selected?.unrealizedProfit) || 0,
markPrice,
}; };
} }
@@ -47,3 +51,25 @@ export function calcTrailingActivationPrice(entryPrice: number, qty: number, sid
} }
return entryPrice - profit / Math.abs(qty); return entryPrice - profit / Math.abs(qty);
} }
/**
* Return true if the intended order price is within the allowed deviation from mark price.
* - For BUY: orderPrice must be <= markPrice * (1 + maxPct)
* - For SELL: orderPrice must be >= markPrice * (1 - maxPct)
* If markPrice is null/invalid, the check passes (no protection possible).
*/
export function isOrderPriceAllowedByMark(params: {
side: "BUY" | "SELL";
orderPrice: number | null | undefined;
markPrice: number | null | undefined;
maxPct: number;
}): boolean {
const { side, orderPrice, markPrice, maxPct } = params;
const price = Number(orderPrice);
const mark = Number(markPrice);
if (!Number.isFinite(price) || !Number.isFinite(mark) || mark <= 0) return true;
if (side === "BUY") {
return price <= mark * (1 + Math.max(0, maxPct));
}
return price >= mark * (1 - Math.max(0, maxPct));
}