From dc6f9ee72efbeed932d7e0fb152a1c19d583dce7 Mon Sep 17 00:00:00 2001 From: discountry Date: Wed, 24 Sep 2025 03:49:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BA=A4=E6=98=93=E5=BC=95?= =?UTF-8?q?=E6=93=8E=EF=BC=8C=E6=B7=BB=E5=8A=A0=E6=9C=80=E5=A4=A7=E5=B9=B3?= =?UTF-8?q?=E4=BB=93=E6=BB=91=E7=82=B9=E7=99=BE=E5=88=86=E6=AF=94=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=BB=A5=E4=BC=98=E5=8C=96=E4=BB=B7=E6=A0=BC=E6=93=8D?= =?UTF-8?q?=E6=8E=A7=E4=BF=9D=E6=8A=A4=E9=80=BB=E8=BE=91=EF=BC=8C=E7=A1=AE?= =?UTF-8?q?=E4=BF=9D=E5=9C=A8=E5=B8=82=E4=BB=B7=E5=B9=B3=E4=BB=93=E6=97=B6?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC=E4=B8=8E=E6=A0=87=E8=AE=B0=E4=BB=B7=E6=A0=BC?= =?UTF-8?q?=E7=9A=84=E5=81=8F=E5=B7=AE=E5=9C=A8=E5=85=81=E8=AE=B8=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E5=86=85=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config.ts | 7 +++++ src/core/maker-engine.ts | 16 ++++++++-- src/core/offset-maker-engine.ts | 23 ++++++++++++-- src/core/order-coordinator.ts | 53 +++++++++++++++++++++++++++++---- src/core/trend-engine.ts | 48 ++++++++++++++++++++++++++--- src/utils/strategy.ts | 30 +++++++++++++++++-- 6 files changed, 161 insertions(+), 16 deletions(-) diff --git a/src/config.ts b/src/config.ts index c3a24d3..8be3aeb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 + ), }; diff --git a/src/core/maker-engine.ts b/src/core/maker-engine.ts index e9a33d1..34e1fc6 100644 --- a/src/core/maker-engine.ts +++ b/src/core/maker-engine.ts @@ -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)) { diff --git a/src/core/offset-maker-engine.ts b/src/core/offset-maker-engine.ts index d30bee0..0ea7b9c 100644 --- a/src/core/offset-maker-engine.ts +++ b/src/core/offset-maker-engine.ts @@ -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)) { diff --git a/src/core/order-coordinator.ts b/src/core/order-coordinator.ts index 351a57d..4ab13ed 100644 --- a/src/core/order-coordinator.ts +++ b/src/core/order-coordinator.ts @@ -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; export type OrderTimerMap = Record | null>; export type OrderPendingMap = Record; 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 { 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 { 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 { 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 { 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 { const type = "MARKET"; if (isOperating(locks, type)) return; + if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return; const params: CreateOrderParams = { symbol, side, diff --git a/src/core/trend-engine.ts b/src/core/trend-engine.ts index 97991b6..fa95a41 100644 --- a/src/core/trend-engine.ts +++ b/src/core/trend-engine.ts @@ -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)}`); diff --git a/src/utils/strategy.ts b/src/utils/strategy.ts index db24ccf..8623bc3 100644 --- a/src/utils/strategy.ts +++ b/src/utils/strategy.ts @@ -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)); +}