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}")