mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
更新交易引擎,添加最大平仓滑点百分比配置以优化价格操控保护逻辑,确保在市价平仓时价格与标记价格的偏差在允许范围内。
This commit is contained in:
@@ -9,6 +9,7 @@ export interface TradingConfig {
|
||||
pollIntervalMs: number;
|
||||
maxLogEntries: number;
|
||||
klineInterval: string;
|
||||
maxCloseSlippagePct: 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),
|
||||
maxLogEntries: parseNumber(process.env.MAX_LOG_ENTRIES, 200),
|
||||
klineInterval: process.env.KLINE_INTERVAL ?? "1m",
|
||||
maxCloseSlippagePct: parseNumber(process.env.MAX_CLOSE_SLIPPAGE_PCT, 0.05),
|
||||
};
|
||||
|
||||
export interface MakerConfig {
|
||||
@@ -39,6 +41,7 @@ export interface MakerConfig {
|
||||
askOffset: number;
|
||||
refreshIntervalMs: number;
|
||||
maxLogEntries: number;
|
||||
maxCloseSlippagePct: number;
|
||||
}
|
||||
|
||||
export const makerConfig: MakerConfig = {
|
||||
@@ -50,4 +53,8 @@ export const makerConfig: MakerConfig = {
|
||||
askOffset: parseNumber(process.env.MAKER_ASK_OFFSET, 0),
|
||||
refreshIntervalMs: parseNumber(process.env.MAKER_REFRESH_INTERVAL_MS, 1500),
|
||||
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
|
||||
),
|
||||
};
|
||||
|
||||
@@ -309,7 +309,11 @@ export class MakerEngine {
|
||||
target.price,
|
||||
target.amount,
|
||||
(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) {
|
||||
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
|
||||
@@ -345,6 +349,9 @@ export class MakerEngine {
|
||||
);
|
||||
|
||||
if (derivedLoss || snapshotLoss) {
|
||||
// 价格操纵保护:只有平仓方向价格与标记价格在阈值内才允许市价平仓
|
||||
const closeSideIsSell = position.positionAmt > 0;
|
||||
const closeSidePrice = closeSideIsSell ? bidPrice : askPrice;
|
||||
this.tradeLog.push(
|
||||
"stop",
|
||||
`触发止损,方向=${position.positionAmt > 0 ? "多" : "空"} 当前亏损=${pnl.toFixed(4)} USDT`
|
||||
@@ -360,7 +367,12 @@ export class MakerEngine {
|
||||
this.pending,
|
||||
position.positionAmt > 0 ? "SELL" : "BUY",
|
||||
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) {
|
||||
if (isUnknownOrderError(error)) {
|
||||
|
||||
@@ -306,6 +306,9 @@ export class OffsetMakerEngine {
|
||||
if (!longExitRequired && !shortExitRequired) return false;
|
||||
|
||||
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(
|
||||
"stop",
|
||||
`深度极端不平衡(${buySum.toFixed(4)} vs ${sellSum.toFixed(4)}), 市价平仓 ${side}`
|
||||
@@ -321,7 +324,12 @@ export class OffsetMakerEngine {
|
||||
this.pending,
|
||||
side,
|
||||
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) {
|
||||
if (isUnknownOrderError(error)) {
|
||||
@@ -396,7 +404,11 @@ export class OffsetMakerEngine {
|
||||
target.price,
|
||||
target.amount,
|
||||
(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) {
|
||||
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
|
||||
@@ -447,7 +459,12 @@ export class OffsetMakerEngine {
|
||||
this.pending,
|
||||
position.positionAmt > 0 ? "SELL" : "BUY",
|
||||
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) {
|
||||
if (isUnknownOrderError(error)) {
|
||||
|
||||
@@ -2,12 +2,45 @@ import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||
import type { AsterOrder, CreateOrderParams } from "../exchanges/types";
|
||||
import { toPrice1Decimal, toQty3Decimal } from "../utils/math";
|
||||
import { isUnknownOrderError } from "../utils/errors";
|
||||
import { isOrderPriceAllowedByMark } from "../utils/strategy";
|
||||
|
||||
export type OrderLockMap = Record<string, boolean>;
|
||||
export type OrderTimerMap = Record<string, ReturnType<typeof setTimeout> | null>;
|
||||
export type OrderPendingMap = Record<string, string | null>;
|
||||
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 {
|
||||
return Boolean(locks[type]);
|
||||
}
|
||||
@@ -92,10 +125,12 @@ export async function placeOrder(
|
||||
price: number,
|
||||
amount: number,
|
||||
log: LogHandler,
|
||||
reduceOnly = false
|
||||
reduceOnly = false,
|
||||
guard?: OrderGuardOptions
|
||||
): Promise<AsterOrder | undefined> {
|
||||
const type = "LIMIT";
|
||||
if (isOperating(locks, type)) return;
|
||||
if (!enforceMarkPriceGuard(side, price, guard, log, "限价单")) return;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
@@ -132,10 +167,12 @@ export async function placeMarketOrder(
|
||||
side: "BUY" | "SELL",
|
||||
amount: number,
|
||||
log: LogHandler,
|
||||
reduceOnly = false
|
||||
reduceOnly = false,
|
||||
guard?: OrderGuardOptions
|
||||
): Promise<AsterOrder | undefined> {
|
||||
const type = "MARKET";
|
||||
if (isOperating(locks, type)) return;
|
||||
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价单")) return;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
@@ -171,10 +208,12 @@ export async function placeStopLossOrder(
|
||||
stopPrice: number,
|
||||
quantity: number,
|
||||
lastPrice: number | null,
|
||||
log: LogHandler
|
||||
log: LogHandler,
|
||||
guard?: OrderGuardOptions
|
||||
): Promise<AsterOrder | undefined> {
|
||||
const type = "STOP_MARKET";
|
||||
if (isOperating(locks, type)) return;
|
||||
if (!enforceMarkPriceGuard(side, stopPrice, guard, log, "止损单")) return;
|
||||
if (lastPrice != null) {
|
||||
if (side === "SELL" && stopPrice >= lastPrice) {
|
||||
log("error", `止损价 ${stopPrice} 高于或等于当前价 ${lastPrice},取消挂单`);
|
||||
@@ -222,10 +261,12 @@ export async function placeTrailingStopOrder(
|
||||
activationPrice: number,
|
||||
quantity: number,
|
||||
callbackRate: number,
|
||||
log: LogHandler
|
||||
log: LogHandler,
|
||||
guard?: OrderGuardOptions
|
||||
): Promise<AsterOrder | undefined> {
|
||||
const type = "TRAILING_STOP_MARKET";
|
||||
if (isOperating(locks, type)) return;
|
||||
if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
@@ -265,10 +306,12 @@ export async function marketClose(
|
||||
pendings: OrderPendingMap,
|
||||
side: "BUY" | "SELL",
|
||||
quantity: number,
|
||||
log: LogHandler
|
||||
log: LogHandler,
|
||||
guard?: OrderGuardOptions
|
||||
): Promise<void> {
|
||||
const type = "MARKET";
|
||||
if (isOperating(locks, type)) return;
|
||||
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
|
||||
@@ -286,7 +286,13 @@ export class TrendEngine {
|
||||
this.pending,
|
||||
side,
|
||||
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.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(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
@@ -404,7 +427,16 @@ export class TrendEngine {
|
||||
this.pending,
|
||||
direction === "long" ? "SELL" : "BUY",
|
||||
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"}`);
|
||||
} catch (err) {
|
||||
@@ -439,7 +471,11 @@ export class TrendEngine {
|
||||
stopPrice,
|
||||
quantity,
|
||||
lastPrice,
|
||||
(type, detail) => this.tradeLog.push(type, detail)
|
||||
(type, detail) => this.tradeLog.push(type, detail),
|
||||
{
|
||||
markPrice: position.markPrice,
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `挂止损单失败: ${String(err)}`);
|
||||
@@ -484,7 +520,11 @@ export class TrendEngine {
|
||||
activationPrice,
|
||||
quantity,
|
||||
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) {
|
||||
this.tradeLog.push("error", `挂动态止盈失败: ${String(err)}`);
|
||||
|
||||
+28
-2
@@ -4,15 +4,16 @@ export interface PositionSnapshot {
|
||||
positionAmt: number;
|
||||
entryPrice: number;
|
||||
unrealizedProfit: number;
|
||||
markPrice: number | null;
|
||||
}
|
||||
|
||||
export function getPosition(snapshot: AsterAccountSnapshot | null, symbol: string): PositionSnapshot {
|
||||
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) ?? [];
|
||||
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 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.sort((a, b) => Math.abs(Number(b.positionAmt)) - Math.abs(Number(a.positionAmt)))[0] ??
|
||||
positions[0];
|
||||
const rawMark = Number(selected?.markPrice);
|
||||
const markPrice = Number.isFinite(rawMark) && rawMark > 0 ? rawMark : null;
|
||||
return {
|
||||
positionAmt: Number(selected?.positionAmt) || 0,
|
||||
entryPrice: Number(selected?.entryPrice) || 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 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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user