diff --git a/frontend/src/components/StatsDashboard.jsx b/frontend/src/components/StatsDashboard.jsx
index d923153..8eeda3a 100644
--- a/frontend/src/components/StatsDashboard.jsx
+++ b/frontend/src/components/StatsDashboard.jsx
@@ -21,6 +21,21 @@ const StatsDashboard = () => {
}
}
+ const fmtPrice = (v) => {
+ const n = Number(v)
+ if (!isFinite(n)) return '-'
+ const a = Math.abs(n)
+ // 低价币需要更高精度,否则“止损价=入场价”只是显示被四舍五入了
+ const dp =
+ a >= 1000 ? 2 :
+ a >= 100 ? 3 :
+ a >= 1 ? 4 :
+ a >= 0.01 ? 6 :
+ a >= 0.0001 ? 8 :
+ 10
+ return n.toFixed(dp).replace(/\.?0+$/, '')
+ }
+
useEffect(() => {
loadDashboard()
loadTradingConfig()
@@ -483,9 +498,9 @@ const StatsDashboard = () => {
名义: {entryValue >= 0.01 ? entryValue.toFixed(2) : entryValue.toFixed(4)} USDT
保证金: {margin >= 0.01 ? margin.toFixed(2) : margin.toFixed(4)} USDT
- 入场价: {entryPrice.toFixed(4)}
+ 入场价: {fmtPrice(entryPrice)}
{trade.mark_price && (
- 标记价: {markPrice.toFixed(4)}
+ 标记价: {fmtPrice(markPrice)}
)}
{trade.leverage && (
杠杆: {trade.leverage}x
@@ -510,22 +525,22 @@ const StatsDashboard = () => {
止损: -{stopLossPercent.toFixed(2)}%
(of margin)
- (价: {stopLossPrice.toFixed(4)})
+ (价: {fmtPrice(stopLossPrice)})
(金额: -{stopLossAmount >= 0.01 ? stopLossAmount.toFixed(2) : stopLossAmount.toFixed(4)} USDT)
止盈: +{takeProfitPercent.toFixed(2)}%
(of margin)
- (价: {takeProfitPrice.toFixed(4)})
+ (价: {fmtPrice(takeProfitPrice)})
(金额: +{takeProfitAmount >= 0.01 ? takeProfitAmount.toFixed(2) : takeProfitAmount.toFixed(4)} USDT)
{(trade.take_profit_1 || trade.take_profit_2) && (
{trade.take_profit_1 && (
- TP1: {parseFloat(trade.take_profit_1).toFixed(4)}
+ TP1: {fmtPrice(parseFloat(trade.take_profit_1))}
)}
{trade.take_profit_2 && (
- TP2: {parseFloat(trade.take_profit_2).toFixed(4)}
+ TP2: {fmtPrice(parseFloat(trade.take_profit_2))}
)}
)}
diff --git a/trading_system/binance_client.py b/trading_system/binance_client.py
index 9484ade..ed615dd 100644
--- a/trading_system/binance_client.py
+++ b/trading_system/binance_client.py
@@ -858,6 +858,51 @@ class BinanceClient:
except Exception:
return BinanceClient._format_decimal_str(p)
+ @staticmethod
+ def _format_price_str_with_rounding(price: float, symbol_info: Optional[Dict], rounding_mode: str) -> str:
+ """
+ 通用价格格式化(tickSize/pricePrecision 对齐),并允许显式指定 ROUND_UP / ROUND_DOWN。
+ rounding_mode: "UP" 或 "DOWN"
+ """
+ try:
+ from decimal import Decimal, ROUND_DOWN, ROUND_UP
+ except Exception:
+ return str(round(float(price), 8))
+
+ tick = 0.0
+ pp = 8
+ try:
+ tick = float(symbol_info.get("tickSize", 0) or 0) if symbol_info else 0.0
+ except Exception:
+ tick = 0.0
+ try:
+ pp = int(symbol_info.get("pricePrecision", 8) or 8) if symbol_info else 8
+ except Exception:
+ pp = 8
+
+ p = Decimal(str(price))
+ rounding = ROUND_UP if str(rounding_mode).upper() == "UP" else ROUND_DOWN
+
+ # tickSize 优先
+ try:
+ t = Decimal(str(tick))
+ if t > 0:
+ q = p / t
+ q2 = q.to_integral_value(rounding=rounding)
+ p2 = q2 * t
+ return BinanceClient._format_decimal_str(p2)
+ except Exception:
+ pass
+
+ # pricePrecision 兜底
+ try:
+ if pp <= 0:
+ return BinanceClient._format_decimal_str(p.to_integral_value(rounding=rounding))
+ p2 = p.quantize(Decimal(f"1e-{pp}"), rounding=rounding)
+ return BinanceClient._format_decimal_str(p2)
+ except Exception:
+ return BinanceClient._format_decimal_str(p)
+
@staticmethod
def _adjust_price_to_tick(price: float, tick_size: float, side: str) -> float:
"""
@@ -1252,6 +1297,165 @@ class BinanceClient:
except BinanceAPIException as e:
logger.error(f"取消订单失败: {e}")
return False
+
+ async def get_open_orders(self, symbol: str) -> List[Dict]:
+ """
+ 获取某交易对的未成交委托(用于防止重复挂保护单)。
+ """
+ try:
+ orders = await self.client.futures_get_open_orders(symbol=symbol)
+ return orders if isinstance(orders, list) else []
+ except Exception as e:
+ logger.debug(f"{symbol} 获取未成交委托失败: {e}")
+ return []
+
+ async def cancel_open_orders_by_types(self, symbol: str, types: set[str]) -> int:
+ """
+ 取消指定类型的未成交委托(只取消保护单相关类型,避免重复下单)。
+ 返回取消数量。
+ """
+ try:
+ want = {str(t).upper() for t in (types or set())}
+ if not want:
+ return 0
+ orders = await self.get_open_orders(symbol)
+ cancelled = 0
+ for o in orders:
+ try:
+ if not isinstance(o, dict):
+ continue
+ otype = str(o.get("type") or "").upper()
+ oid = o.get("orderId")
+ if otype in want and oid:
+ ok = await self.cancel_order(symbol, int(oid))
+ if ok:
+ cancelled += 1
+ except Exception:
+ continue
+ return cancelled
+ except Exception:
+ return 0
+
+ async def place_trigger_close_position_order(
+ self,
+ symbol: str,
+ position_direction: str,
+ trigger_type: str,
+ stop_price: float,
+ current_price: Optional[float] = None,
+ working_type: str = "MARK_PRICE",
+ ) -> Optional[Dict]:
+ """
+ 在币安侧挂“保护单”,用于止损/止盈:
+ - STOP_MARKET / TAKE_PROFIT_MARKET
+ - closePosition=True(自动平掉该 symbol 的当前仓位)
+
+ 注意:这类单子不会在本地生成 exit_reason;触发后我们靠“持仓同步/订单同步”去回写数据库。
+ """
+ try:
+ symbol_info = await self.get_symbol_info(symbol)
+ dual = None
+ try:
+ dual = await self._get_dual_side_position()
+ except Exception:
+ dual = None
+
+ pd = (position_direction or "").upper()
+ if pd not in {"BUY", "SELL"}:
+ return None
+
+ ttype = str(trigger_type or "").upper()
+ if ttype not in {"STOP_MARKET", "TAKE_PROFIT_MARKET"}:
+ return None
+
+ close_side = "SELL" if pd == "BUY" else "BUY"
+
+ # stopPrice 的“避免立即触发”修正(按 MARK_PRICE)
+ cp = None
+ try:
+ cp = float(current_price) if current_price is not None else None
+ except Exception:
+ cp = None
+
+ tick = 0.0
+ pp = 8
+ try:
+ tick = float(symbol_info.get("tickSize", 0) or 0) if symbol_info else 0.0
+ except Exception:
+ tick = 0.0
+ try:
+ pp = int(symbol_info.get("pricePrecision", 8) or 8) if symbol_info else 8
+ except Exception:
+ pp = 8
+
+ min_step = tick if tick and tick > 0 else (10 ** (-pp) if pp and pp > 0 else 1e-8)
+
+ sp = float(stop_price or 0)
+ if sp <= 0:
+ return None
+
+ # 触发方向约束:
+ # - long 止损:价格 <= stopPrice(stopPrice 应 < current)
+ # - short 止损:价格 >= stopPrice(stopPrice 应 > current)
+ # - long 止盈:价格 >= stopPrice(stopPrice 应 > current)
+ # - short 止盈:价格 <= stopPrice(stopPrice 应 < current)
+ if cp and cp > 0:
+ if ttype == "STOP_MARKET":
+ if pd == "BUY" and sp >= cp:
+ sp = max(0.0, cp - min_step)
+ if pd == "SELL" and sp <= cp:
+ sp = cp + min_step
+ if ttype == "TAKE_PROFIT_MARKET":
+ if pd == "BUY" and sp <= cp:
+ sp = cp + min_step
+ if pd == "SELL" and sp >= cp:
+ sp = max(0.0, cp - min_step)
+
+ # rounding 规则(提高命中概率,避免“显示等于入场价”的误差带来立即触发/永不触发):
+ # 止损:long 用 UP(更靠近当前价),short 用 DOWN
+ # 止盈:long 用 DOWN(更靠近当前价),short 用 UP
+ rounding_mode = "DOWN"
+ if ttype == "STOP_MARKET":
+ rounding_mode = "UP" if pd == "BUY" else "DOWN"
+ else: # TAKE_PROFIT_MARKET
+ rounding_mode = "DOWN" if pd == "BUY" else "UP"
+
+ stop_price_str = self._format_price_str_with_rounding(sp, symbol_info, rounding_mode)
+
+ params: Dict[str, Any] = {
+ "symbol": symbol,
+ "side": close_side,
+ "type": ttype,
+ "stopPrice": stop_price_str,
+ "closePosition": True,
+ "workingType": working_type,
+ }
+
+ # 对冲模式:必须指定 positionSide
+ if dual is True:
+ params["positionSide"] = "LONG" if pd == "BUY" else "SHORT"
+
+ try:
+ return await self.client.futures_create_order(**params)
+ except BinanceAPIException as e:
+ code = getattr(e, "code", None)
+ # -4061: positionSide 不匹配 -> 兜底切换一次
+ if code == -4061:
+ retry = dict(params)
+ if "positionSide" in retry:
+ retry.pop("positionSide", None)
+ else:
+ retry["positionSide"] = "LONG" if pd == "BUY" else "SHORT"
+ return await self.client.futures_create_order(**retry)
+ raise
+
+ except BinanceAPIException as e:
+ # 常见:Order would immediately trigger(止损/止盈价不在正确一侧)
+ logger.warning(f"{symbol} 挂保护单失败({trigger_type}): {e}")
+ return None
+ except Exception as e:
+ logger.warning(f"{symbol} 挂保护单失败({trigger_type}): {e}")
+ return None
async def set_leverage(self, symbol: str, leverage: int = 10) -> bool:
"""
diff --git a/trading_system/position_manager.py b/trading_system/position_manager.py
index 0f3040f..648d114 100644
--- a/trading_system/position_manager.py
+++ b/trading_system/position_manager.py
@@ -590,6 +590,16 @@ class PositionManager:
f"数量={float(binance_position.get('positionAmt', 0)):.4f}, "
f"入场价={float(binance_position.get('entryPrice', 0)):.4f}"
)
+ # 在币安侧挂“止损/止盈保护单”,避免仅依赖本地监控(服务重启/网络波动时更安全)
+ try:
+ current_mark = None
+ try:
+ current_mark = float(binance_position.get("markPrice", 0) or 0) or None
+ except Exception:
+ current_mark = None
+ await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_mark)
+ except Exception as e:
+ logger.warning(f"{symbol} 挂币安止盈止损失败(不影响持仓监控): {e}")
else:
logger.warning(
f"{symbol} [开仓验证] ⚠️ 币安账户中没有持仓,可能订单未成交或被立即平仓"
@@ -637,6 +647,22 @@ class PositionManager:
"""
try:
logger.info(f"{symbol} [平仓] 开始平仓操作 (原因: {reason})")
+
+ # 先取消币安侧的保护单(避免平仓后残留委托导致反向开仓/误触发)
+ try:
+ info0 = self.active_positions.get(symbol) if hasattr(self, "active_positions") else None
+ if info0 and isinstance(info0, dict):
+ for k in ("exchangeSlOrderId", "exchangeTpOrderId"):
+ oid = info0.get(k)
+ if oid:
+ try:
+ await self.client.cancel_order(symbol, int(oid))
+ except Exception:
+ pass
+ info0.pop("exchangeSlOrderId", None)
+ info0.pop("exchangeTpOrderId", None)
+ except Exception:
+ pass
# 获取当前持仓
positions = await self.client.get_open_positions()
@@ -1026,6 +1052,82 @@ class PositionManager:
q = round(q, qty_precision)
q = round(q, qty_precision)
return max(0.0, q)
+
+ async def _ensure_exchange_sltp_orders(self, symbol: str, position_info: Dict, current_price: Optional[float] = None) -> None:
+ """
+ 在币安侧挂止损/止盈保护单(STOP_MARKET + TAKE_PROFIT_MARKET)。
+ 目的:
+ - 服务重启/网络波动时仍有交易所级别保护
+ - 用户在币安界面能看到止损/止盈委托
+ """
+ try:
+ enabled = bool(config.TRADING_CONFIG.get("EXCHANGE_SLTP_ENABLED", True))
+ except Exception:
+ enabled = True
+ if not enabled:
+ return
+
+ if not position_info or not isinstance(position_info, dict):
+ return
+
+ side = (position_info.get("side") or "").upper()
+ if side not in {"BUY", "SELL"}:
+ return
+
+ stop_loss = position_info.get("stopLoss")
+ take_profit = position_info.get("takeProfit2") or position_info.get("takeProfit")
+ try:
+ stop_loss = float(stop_loss) if stop_loss is not None else None
+ except Exception:
+ stop_loss = None
+ try:
+ take_profit = float(take_profit) if take_profit is not None else None
+ except Exception:
+ take_profit = None
+
+ if not stop_loss or not take_profit:
+ return
+
+ # 防重复:先取消旧的保护单(仅取消特定类型,避免误伤普通挂单)
+ try:
+ await self.client.cancel_open_orders_by_types(
+ symbol, {"STOP_MARKET", "TAKE_PROFIT_MARKET", "TRAILING_STOP_MARKET"}
+ )
+ except Exception:
+ pass
+
+ sl_order = await self.client.place_trigger_close_position_order(
+ symbol=symbol,
+ position_direction=side,
+ trigger_type="STOP_MARKET",
+ stop_price=stop_loss,
+ current_price=current_price,
+ working_type="MARK_PRICE",
+ )
+ tp_order = await self.client.place_trigger_close_position_order(
+ symbol=symbol,
+ position_direction=side,
+ trigger_type="TAKE_PROFIT_MARKET",
+ stop_price=take_profit,
+ current_price=current_price,
+ working_type="MARK_PRICE",
+ )
+
+ try:
+ position_info["exchangeSlOrderId"] = sl_order.get("orderId") if isinstance(sl_order, dict) else None
+ except Exception:
+ position_info["exchangeSlOrderId"] = None
+ try:
+ position_info["exchangeTpOrderId"] = tp_order.get("orderId") if isinstance(tp_order, dict) else None
+ except Exception:
+ position_info["exchangeTpOrderId"] = None
+
+ if position_info.get("exchangeSlOrderId") or position_info.get("exchangeTpOrderId"):
+ logger.info(
+ f"{symbol} 已挂币安保护单: "
+ f"SL={position_info.get('exchangeSlOrderId') or '-'} "
+ f"TP={position_info.get('exchangeTpOrderId') or '-'}"
+ )
async def check_stop_loss_take_profit(self) -> List[str]:
"""
@@ -2019,6 +2121,16 @@ class PositionManager:
}
self.active_positions[symbol] = position_info
logger.info(f"{symbol} 已创建临时持仓记录用于监控")
+ # 也为“现有持仓”补挂交易所保护单(重启/掉线更安全)
+ try:
+ mp = None
+ try:
+ mp = float(position.get("markPrice", 0) or 0) or None
+ except Exception:
+ mp = None
+ await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=mp or current_price)
+ except Exception as e:
+ logger.warning(f"{symbol} 补挂币安止盈止损失败(不影响监控): {e}")
except Exception as e:
logger.error(f"{symbol} 创建临时持仓记录失败: {e}")