""" 仓位管理模块 - 管理持仓和订单 """ import asyncio import json import logging import random import time import aiohttp from pathlib import Path from typing import Dict, List, Optional from datetime import datetime try: from .binance_client import BinanceClient, AlgoOrderPositionUnavailableError from .risk_manager import RiskManager from . import config except ImportError: from binance_client import BinanceClient, AlgoOrderPositionUnavailableError from risk_manager import RiskManager import config logger = logging.getLogger(__name__) # 尝试导入数据库模型(如果可用) DB_AVAILABLE = False Trade = None get_beijing_time = None try: import sys from pathlib import Path project_root = Path(__file__).parent.parent backend_path = project_root / 'backend' if backend_path.exists(): sys.path.insert(0, str(backend_path)) from database.models import Trade, get_beijing_time DB_AVAILABLE = True logger.info("✓ 数据库模型导入成功,交易记录将保存到数据库") else: logger.warning("⚠ backend目录不存在,无法使用数据库功能") DB_AVAILABLE = False except ImportError as e: logger.warning(f"⚠ 无法导入数据库模型: {e}") logger.warning(" 交易记录将不会保存到数据库") DB_AVAILABLE = False except Exception as e: logger.warning(f"⚠ 数据库初始化失败: {e}") logger.warning(" 交易记录将不会保存到数据库") DB_AVAILABLE = False # 如果没有导入get_beijing_time,创建一个本地版本 if get_beijing_time is None: from datetime import datetime, timezone, timedelta BEIJING_TZ = timezone(timedelta(hours=8)) def get_beijing_time(): """获取当前北京时间(UTC+8)""" return datetime.now(BEIJING_TZ).replace(tzinfo=None) # 落库失败独立日志:币安已成交但未写入 DB 时写入此文件,便于排查与对账(不依赖 DB/Redis) _FAILURE_LOG_DIR = None _FAILURE_LOG_PATH = None def _get_db_failure_log_path(): global _FAILURE_LOG_DIR, _FAILURE_LOG_PATH if _FAILURE_LOG_PATH is not None: return _FAILURE_LOG_PATH try: root = Path(__file__).parent.parent _FAILURE_LOG_DIR = root / "logs" _FAILURE_LOG_DIR.mkdir(parents=True, exist_ok=True) _FAILURE_LOG_PATH = _FAILURE_LOG_DIR / "trade_db_failures.log" return _FAILURE_LOG_PATH except Exception: return None def _log_trade_db_failure(symbol, entry_order_id, side, quantity, entry_price, account_id, reason, error_type=None, error_message=None): """将「成交但落库失败」记录到独立日志文件(一行 JSON),便于 grep/脚本统计,不依赖 DB/Redis。""" path = _get_db_failure_log_path() if not path: return try: rec = { "ts": datetime.utcnow().isoformat() + "Z", "symbol": symbol, "entry_order_id": entry_order_id, "side": side, "quantity": quantity, "entry_price": entry_price, "account_id": account_id, "reason": reason, } if error_type: rec["error_type"] = error_type if error_message: rec["error_message"] = (str(error_message) or "")[:500] with open(path, "a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n") except Exception as e: logger.warning(f"写入落库失败日志失败: {e}") _TRAILING_LOG_PATH: Optional[Path] = None def _get_trailing_stop_log_path() -> Optional[Path]: """保本/移动止损专用日志路径:logs/profit_protection.log,便于 tail/grep 确认是否执行。""" global _TRAILING_LOG_PATH if _TRAILING_LOG_PATH is not None: return _TRAILING_LOG_PATH try: root = Path(__file__).parent.parent log_dir = root / "logs" log_dir.mkdir(parents=True, exist_ok=True) _TRAILING_LOG_PATH = log_dir / "profit_protection.log" return _TRAILING_LOG_PATH except Exception: return None def _log_trailing_stop_event(account_id: int, symbol: str, event: str, **kwargs): """将保本/移动止损相关事件写入专用日志(一行 JSON),便于确认是否正常执行。""" path = _get_trailing_stop_log_path() if not path: return try: rec = { "ts": datetime.utcnow().isoformat() + "Z", "account_id": account_id, "symbol": symbol, "event": event, } for k, v in kwargs.items(): if v is not None and k not in rec: if isinstance(v, float): rec[k] = round(v, 6) else: rec[k] = v with open(path, "a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n") except Exception as e: logger.debug(f"写入保本/移动止损日志失败: {e}") # User Data Stream 缓存(持仓/余额优先读 WS,减少 REST) try: from .user_data_stream import get_stream_instance, get_positions_from_cache, get_balance_from_cache except Exception: try: from user_data_stream import get_stream_instance, get_positions_from_cache, get_balance_from_cache except Exception: get_stream_instance = lambda: None get_positions_from_cache = lambda min_notional=1.0: [] get_balance_from_cache = lambda: None class PositionManager: """仓位管理类""" def __init__(self, client: BinanceClient, risk_manager: RiskManager, account_id: int = None): """ 初始化仓位管理器 Args: client: 币安客户端 risk_manager: 风险管理器 account_id: 账户ID(默认从环境变量或配置获取) """ self.client = client self.risk_manager = risk_manager # 确定 account_id if account_id is not None: self.account_id = int(account_id) else: # 尝试从环境变量获取 import os try: self.account_id = int(os.getenv("ATS_ACCOUNT_ID") or os.getenv("ACCOUNT_ID") or 1) except: self.account_id = 1 self.active_positions: Dict[str, Dict] = {} self._monitor_tasks: Dict[str, asyncio.Task] = {} # WebSocket监控任务字典 self._monitoring_enabled = True # 是否启用实时监控 self._pending_entry_orders: Dict[str, Dict] = {} # 未成交的入场订单(避免重复挂单) self._last_entry_attempt_ms: Dict[str, int] = {} # 每个symbol的最近一次尝试(冷却/去抖) # 自动平仓去抖/限流(避免止损触发后反复下单/刷屏) self._last_auto_close_attempt_ms: Dict[str, int] = {} self._last_auto_close_fail_log_ms: Dict[str, int] = {} async def _get_open_positions(self, force_rest: bool = False) -> List[Dict]: """优先使用 User Data Stream 持仓缓存(Redis),无缓存或未启动时走 REST。多账号时必须传 account_id 读对应缓存。""" if not force_rest and get_stream_instance() is not None: min_notional = float(getattr(config, "POSITION_MIN_NOTIONAL_USDT", 1.0) or 1.0) redis_cache = getattr(self.client, "redis_cache", None) cached = await get_positions_from_cache(min_notional, redis_cache, account_id=self.account_id) if cached is not None: return cached return await self.client.get_open_positions() async def _get_account_balance(self) -> Dict: """优先使用 User Data Stream 余额缓存(Redis),无缓存时走 REST。""" if get_stream_instance() is not None: redis_cache = getattr(self.client, "redis_cache", None) bal = await get_balance_from_cache(redis_cache) if bal is not None: return bal return await self.client.get_account_balance() @staticmethod def _pct_like_to_ratio(v: float) -> float: """ 将“看起来像百分比”的值转换为比例(0~1)。 兼容两种来源: - 前端/后端按“比例”存储:0.006 表示 0.6% - 历史/默认值按“百分比数值”存储:0.6 表示 0.6% 经验规则: - v > 1: 认为是 60/100 这种百分数,除以100 - 0.05 < v <= 1: 也更可能是“0.6% 这种写法”,除以100 - v <= 0.05: 更可能已经是比例(<=5%) """ try: x = float(v or 0.0) except Exception: x = 0.0 if x <= 0: return 0.0 if x > 1.0: return x / 100.0 if x > 0.05: return x / 100.0 return x def _calc_limit_entry_price(self, current_price: float, side: str, offset_ratio: float) -> float: """根据当前价与偏移比例,计算限价入场价(BUY: 下方回调;SELL: 上方回调)""" try: cp = float(current_price) except Exception: cp = 0.0 try: off = float(offset_ratio or 0.0) except Exception: off = 0.0 if cp <= 0: return 0.0 if (side or "").upper() == "BUY": return cp * (1 - off) return cp * (1 + off) async def _wait_for_order_filled( self, symbol: str, order_id: int, timeout_sec: int = 30, poll_sec: float = 1.0, ) -> Dict: """ 等待订单成交(FILLED),返回: { ok, status, avg_price, executed_qty, raw } """ deadline = time.time() + max(1, int(timeout_sec or 1)) last_status = None while time.time() < deadline: try: info = await self.client.client.futures_get_order(symbol=symbol, orderId=order_id, recvWindow=20000) status = info.get("status") last_status = status if status == "FILLED": avg_price = float(info.get("avgPrice", 0) or 0) or float(info.get("price", 0) or 0) executed_qty = float(info.get("executedQty", 0) or 0) return {"ok": True, "status": status, "avg_price": avg_price, "executed_qty": executed_qty, "raw": info} if status in ("CANCELED", "REJECTED", "EXPIRED"): return {"ok": False, "status": status, "avg_price": 0, "executed_qty": float(info.get("executedQty", 0) or 0), "raw": info} except Exception: # 忽略单次失败,继续轮询 pass await asyncio.sleep(max(0.2, float(poll_sec or 1.0))) return {"ok": False, "status": last_status or "TIMEOUT", "avg_price": 0, "executed_qty": 0, "raw": None} async def open_position( self, symbol: str, change_percent: float, leverage: int = 10, trade_direction: Optional[str] = None, entry_reason: str = '', signal_strength: Optional[int] = None, market_regime: Optional[str] = None, trend_4h: Optional[str] = None, atr: Optional[float] = None, klines: Optional[List] = None, bollinger: Optional[Dict] = None, entry_context: Optional[Dict] = None, ) -> Optional[Dict]: """ 开仓 Args: symbol: 交易对 change_percent: 涨跌幅百分比 leverage: 杠杆倍数 Returns: 订单信息,失败返回None """ try: # 0) 防止同一 symbol 重复挂入场单/快速重复尝试(去抖 + 冷却) now_ms = int(time.time() * 1000) # ⚠️ 关键修复:检查是否已经在持仓中(防止重复开仓) if symbol in self.active_positions: logger.info(f"{symbol} [入场] 已在持仓中 (active_positions),跳过重复开仓") return None cooldown_sec = int(config.TRADING_CONFIG.get("ENTRY_SYMBOL_COOLDOWN_SEC", 120) or 0) last_ms = self._last_entry_attempt_ms.get(symbol) if last_ms and cooldown_sec > 0 and now_ms - last_ms < cooldown_sec * 1000: logger.info(f"{symbol} [入场] 冷却中({cooldown_sec}s),跳过本次自动开仓") return None pending = self._pending_entry_orders.get(symbol) if pending and pending.get("order_id"): logger.info(f"{symbol} [入场] 已有未完成入场订单(orderId={pending.get('order_id')}),跳过重复开仓") return None # 标记本次尝试(无论最终是否成交,都避免短时间内反复开仓/挂单) self._last_entry_attempt_ms[symbol] = now_ms # 判断是否应该交易 if not await self.risk_manager.should_trade(symbol, change_percent): return None # 开仓前二次校验持仓数(同一轮扫描多 symbol 通过 should_trade 后串行开仓,避免超限) try: max_open = int(config.TRADING_CONFIG.get("MAX_OPEN_POSITIONS", 0) or 0) if max_open > 0: positions = await self.client.get_open_positions(include_low_notional=True) if positions and len(positions) >= max_open: logger.info(f"{symbol} [入场] 开仓前复核:持仓数已达上限 {len(positions)}/{max_open},跳过") return None except Exception as e: logger.debug(f"{symbol} 开仓前持仓数复核异常(继续): {e}") # 设置杠杆(确保为 int,避免动态杠杆传入 float 导致 API/range 报错) actual_leverage = await self.client.set_leverage(symbol, int(leverage)) if actual_leverage <= 0: logger.error(f"{symbol} 无法设置有效杠杆,跳过开仓") return None # 使用实际生效的杠杆(可能被降级) if actual_leverage != int(leverage): logger.warning(f"{symbol} 杠杆被调整: {int(leverage)}x -> {actual_leverage}x") leverage = actual_leverage # 计算仓位大小(传入实际使用的杠杆) # ⚠️ 优化:先估算止损价格,用于固定风险百分比计算 logger.info(f"开始为 {symbol} 计算仓位大小...") # 获取当前价格用于估算止损 ticker = await self.client.get_ticker_24h(symbol) if not ticker: return None estimated_entry_price = ticker['price'] estimated_side = trade_direction if trade_direction else ('BUY' if change_percent > 0 else 'SELL') # ===== 趋势入场过滤(防止在趋势尾部追价)===== try: use_trend_filter = bool(config.TRADING_CONFIG.get("USE_TREND_ENTRY_FILTER", False)) if use_trend_filter and getattr(self.client, "redis_cache", None): trend_state_key = f"trend_state:{symbol}" trend_state = await self.client.redis_cache.get(trend_state_key) if trend_state: trend_dir = (trend_state.get("direction") or "").upper() signal_price = float(trend_state.get("signal_price") or 0) or None if signal_price and trend_dir in ("BUY", "SELL"): # 使用更实时的价格(如有),否则使用估算价 realtime_price = None try: realtime_price = self.client.get_realtime_price(symbol) except Exception: realtime_price = None current_price = float(realtime_price or estimated_entry_price) max_move = float(config.TRADING_CONFIG.get("MAX_TREND_MOVE_BEFORE_ENTRY", 0.05) or 0.05) too_late = False move_pct = 0.0 if trend_dir == "BUY": # 做多:如果当前价相比信号价已经上涨超过阈值,认为追在高位 if current_price > signal_price: move_pct = (current_price - signal_price) / signal_price if move_pct > max_move: too_late = True elif trend_dir == "SELL": # 做空:如果当前价相比信号价已经下跌超过阈值,认为追在低位 if current_price < signal_price: move_pct = (signal_price - current_price) / signal_price if move_pct > max_move: too_late = True # 只有当当前入场方向与趋势方向一致时,才应用“太晚不追”规则 if estimated_side.upper() == trend_dir and too_late: logger.info( f"{symbol} [入场过滤] 趋势方向={trend_dir}, 信号价={signal_price:.6f}, " f"当前价={current_price:.6f}, 累计趋势幅度={move_pct*100:.2f}%>允许上限={max_move*100:.2f}%," f"认为本轮趋势入场时机已错过,跳过自动开仓" ) return None except Exception as e: logger.debug(f"{symbol} 趋势入场过滤时出错(忽略,按正常逻辑继续): {e}") # 估算止损价格(用于固定风险计算) estimated_stop_loss = None if atr and atr > 0: atr_multiplier = config.TRADING_CONFIG.get('ATR_STOP_LOSS_MULTIPLIER', 2.5) # 默认2.5,放宽止损提升胜率 if estimated_side == 'BUY': estimated_stop_loss = estimated_entry_price - (atr * atr_multiplier) else: # SELL estimated_stop_loss = estimated_entry_price + (atr * atr_multiplier) quantity, adjusted_leverage = await self.risk_manager.calculate_position_size( symbol, change_percent, leverage=leverage, entry_price=estimated_entry_price, stop_loss_price=estimated_stop_loss, side=estimated_side, atr=atr, signal_strength=signal_strength ) # 如果 RiskManager 建议了更低的安全杠杆,则应用它 if quantity is not None and adjusted_leverage is not None and adjusted_leverage != leverage: logger.info(f"{symbol} 风险控制调整杠杆: {leverage}x -> {adjusted_leverage}x (适应宽止损)") try: new_lev = await self.client.set_leverage(symbol, int(adjusted_leverage)) if new_lev > 0: leverage = new_lev else: logger.warning(f"{symbol} 调整杠杆失败,保持 {leverage}x") except Exception as e: logger.error(f"{symbol} 调整杠杆失败: {e},将使用原杠杆 {leverage}x 继续") if quantity is None: logger.warning(f"❌ {symbol} 仓位计算失败,跳过交易") logger.warning(f" 可能原因:") logger.warning(f" 1. 账户余额不足") logger.warning(f" 2. 单笔仓位超过限制") logger.warning(f" 3. 总仓位超过限制") logger.warning(f" 4. 无法获取价格数据") logger.warning(f" 5. 保证金不足最小要求(MIN_MARGIN_USDT)") logger.warning(f" 6. 名义价值小于0.2 USDT(避免无意义的小单子)") return None logger.info(f"✓ {symbol} 仓位计算成功: {quantity:.4f}") # 确定交易方向(优先使用技术指标信号) if trade_direction: side = trade_direction else: side = 'BUY' if change_percent > 0 else 'SELL' # 获取当前价格 ticker = await self.client.get_ticker_24h(symbol) if not ticker: return None entry_price = ticker['price'] # 获取K线数据用于动态止损计算(从symbol_info中获取,如果可用) klines = None bollinger = None if 'klines' in locals() or 'symbol_info' in locals(): # 尝试从外部传入的symbol_info获取 pass # 计算基于支撑/阻力的动态止损 # 优先使用技术结构(支撑/阻力位、布林带) # 如果无法获取K线数据,回退到ATR或固定止损 if not klines: # 如果没有传入K线数据,尝试获取 try: primary_interval = config.TRADING_CONFIG.get('PRIMARY_INTERVAL', '1h') klines_data = await self.client.get_klines( symbol=symbol, interval=primary_interval, limit=20 # 获取最近20根K线用于计算支撑/阻力 ) klines = klines_data if len(klines_data) >= 10 else None except Exception as e: logger.debug(f"获取K线数据失败,使用固定止损: {e}") klines = None # 在开仓前从Redis重新加载配置,确保使用最新配置(包括ATR参数) # 从Redis读取最新配置(轻量级,即时生效) try: if config._config_manager: config._config_manager.reload_from_redis() config.TRADING_CONFIG = config._get_trading_config() logger.debug(f"{symbol} 开仓前已从Redis重新加载配置") except Exception as e: logger.debug(f"从Redis重新加载配置失败: {e}") # ===== 智能入场(方案C:趋势强更少错过,震荡更保守)===== smart_entry_enabled = bool(config.TRADING_CONFIG.get("SMART_ENTRY_ENABLED", True)) # LIMIT_ORDER_OFFSET_PCT:兼容“比例/百分比”两种存储方式 limit_offset_ratio = self._pct_like_to_ratio(float(config.TRADING_CONFIG.get("LIMIT_ORDER_OFFSET_PCT", 0.5) or 0.0)) # 规则:趋势(trending) 或 4H共振明显 + 强信号 -> 允许“超时后市价兜底(有追价上限)” mr = (market_regime or "").strip().lower() if market_regime else "" t4 = (trend_4h or "").strip().lower() if trend_4h else "" ss = int(signal_strength) if signal_strength is not None else 0 allow_market_fallback = False if mr == "trending": allow_market_fallback = True if ss >= int(config.TRADING_CONFIG.get("SMART_ENTRY_STRONG_SIGNAL", 8) or 8) and t4 in ("up", "down"): allow_market_fallback = True # 追价上限:超过就不追,宁愿错过(避免回到“无脑追价高频打损”) drift_ratio_trending = self._pct_like_to_ratio(float(config.TRADING_CONFIG.get("ENTRY_MAX_DRIFT_PCT_TRENDING", 0.6) or 0.6)) drift_ratio_ranging = self._pct_like_to_ratio(float(config.TRADING_CONFIG.get("ENTRY_MAX_DRIFT_PCT_RANGING", 0.3) or 0.3)) max_drift_ratio = drift_ratio_trending if allow_market_fallback else drift_ratio_ranging # 总等待/追价参数 timeout_sec = int(config.TRADING_CONFIG.get("ENTRY_TIMEOUT_SEC", 180) or 180) step_wait_sec = int(config.TRADING_CONFIG.get("ENTRY_STEP_WAIT_SEC", 15) or 15) chase_steps = int(config.TRADING_CONFIG.get("ENTRY_CHASE_MAX_STEPS", 4) or 4) market_fallback_after_sec = int(config.TRADING_CONFIG.get("ENTRY_MARKET_FALLBACK_AFTER_SEC", 45) or 45) # 初始限价(回调入场) current_px = float(entry_price) initial_limit = self._calc_limit_entry_price(current_px, side, limit_offset_ratio) if initial_limit <= 0: return None order = None entry_order_id = None order_status = None actual_entry_price = None filled_quantity = 0.0 entry_mode_used = "limit-only" if not smart_entry_enabled else ("limit+fallback" if allow_market_fallback else "limit-chase") # 生成 client_order_id:先落库 pending,WS/对账 按 client_order_id 匹配完善,确保限价/条件单事后成交也能对应 DB prefix = (config.TRADING_CONFIG.get("SYSTEM_ORDER_ID_PREFIX") or "").strip() prefix = prefix or "ats_" # 无配置时用默认前缀,保证系统单始终有 pending 可对账 client_order_id = f"{prefix}_{int(time.time() * 1000)}_{random.randint(0, 0xFFFF):04x}"[:36] pending_trade_id = None if DB_AVAILABLE and Trade: try: logger.info(f"[DB] {symbol} 写入 pending 记录 client_order_id={client_order_id!r} (保证与下单一致性)") pending_trade_id = Trade.create( symbol=symbol, side=side, quantity=quantity, entry_price=entry_price, leverage=leverage, entry_reason=entry_reason, entry_order_id=None, client_order_id=client_order_id, stop_loss_price=None, take_profit_price=None, take_profit_1=None, take_profit_2=None, atr=atr, notional_usdt=None, margin_usdt=None, account_id=self.account_id, entry_context=entry_context, status="pending", ) logger.info(f"[DB] {symbol} pending 已落库 id={pending_trade_id} client_order_id={client_order_id!r}") except Exception as e: logger.error( f"[DB] {symbol} 创建 pending 记录失败,将导致后续无法按 client_order_id 完善,易产生补单: " f"client_order_id={client_order_id!r} error_type={type(e).__name__} error={e}" ) _log_trade_db_failure( symbol=symbol, entry_order_id=None, side=side, quantity=quantity, entry_price=entry_price, account_id=self.account_id, reason="pending_create_failed", error_type=type(e).__name__, error_message=str(e) ) pending_trade_id = None if not smart_entry_enabled: # 根治方案:关闭智能入场后,回归“纯限价单模式” # - 不追价 # - 不市价兜底 # - 未在确认时间内成交则撤单并跳过(属于策略未触发入场,不是系统错误) confirm_timeout = int(config.TRADING_CONFIG.get("ENTRY_CONFIRM_TIMEOUT_SEC", 30) or 30) logger.info( f"{symbol} [纯限价入场] side={side} | 限价={initial_limit:.6f} (offset={limit_offset_ratio*100:.2f}%) | " f"确认超时={confirm_timeout}s(未成交将撤单跳过)" ) order = await self.client.place_order( symbol=symbol, side=side, quantity=quantity, order_type="LIMIT", price=initial_limit, new_client_order_id=client_order_id ) if not order: if pending_trade_id and DB_AVAILABLE and Trade: try: ok = Trade.update_status(pending_trade_id, "cancelled") if not ok: logger.error(f"[DB] {symbol} 下单失败后更新 pending 为 cancelled 失败 trade_id={pending_trade_id}") except Exception as ex: logger.error(f"[DB] {symbol} 下单失败后更新 pending 为 cancelled 异常 trade_id={pending_trade_id} error={ex}") return None entry_order_id = order.get("orderId") if entry_order_id: self._pending_entry_orders[symbol] = {"order_id": entry_order_id, "created_at_ms": int(time.time() * 1000)} else: # 1) 先挂限价单 logger.info( f"{symbol} [智能入场] 模式={entry_mode_used} | side={side} | " f"marketRegime={market_regime} trend_4h={trend_4h} strength={ss}/10 | " f"初始限价={initial_limit:.6f} (offset={limit_offset_ratio*100:.2f}%) | " f"追价上限={max_drift_ratio*100:.2f}%" ) order = await self.client.place_order( symbol=symbol, side=side, quantity=quantity, order_type="LIMIT", price=initial_limit, new_client_order_id=client_order_id ) if not order: if pending_trade_id and DB_AVAILABLE and Trade: try: ok = Trade.update_status(pending_trade_id, "cancelled") if not ok: logger.error(f"[DB] {symbol} 智能入场首单失败后更新 pending 为 cancelled 失败 trade_id={pending_trade_id}") except Exception as ex: logger.error(f"[DB] {symbol} 智能入场首单失败后更新 pending 为 cancelled 异常 trade_id={pending_trade_id} error={ex}") return None entry_order_id = order.get("orderId") if entry_order_id: self._pending_entry_orders[symbol] = {"order_id": entry_order_id, "created_at_ms": int(time.time() * 1000)} start_ts = time.time() # 2) 分步等待 + 追价(逐步减少 offset),并在趋势强时允许市价兜底(有追价上限) for step in range(max(1, chase_steps)): # 先等待一段时间看是否成交 wait_res = await self._wait_for_order_filled(symbol, int(entry_order_id), timeout_sec=step_wait_sec, poll_sec=1.0) order_status = wait_res.get("status") if wait_res.get("ok"): actual_entry_price = float(wait_res.get("avg_price") or 0) filled_quantity = float(wait_res.get("executed_qty") or 0) break # 未成交:如果超时太久且允许市价兜底,检查追价上限后转市价 elapsed = time.time() - start_ts ticker2 = await self.client.get_ticker_24h(symbol) cur2 = float(ticker2.get("price")) if ticker2 else current_px drift_ratio = 0.0 try: base = float(initial_limit) if float(initial_limit) > 0 else cur2 drift_ratio = abs((cur2 - base) / base) except Exception: drift_ratio = 0.0 if allow_market_fallback and elapsed >= market_fallback_after_sec: if drift_ratio <= max_drift_ratio: try: await self.client.cancel_order(symbol, int(entry_order_id)) except Exception: pass self._pending_entry_orders.pop(symbol, None) logger.info(f"{symbol} [智能入场] 限价超时,且偏离{drift_ratio*100:.2f}%≤{max_drift_ratio*100:.2f}%,转市价兜底") order = await self.client.place_order( symbol=symbol, side=side, quantity=quantity, order_type="MARKET", new_client_order_id=client_order_id ) # 关键:转市价后必须更新 entry_order_id,否则后续会继续查询“已取消的旧限价单”,导致误判 CANCELED try: entry_order_id = order.get("orderId") if isinstance(order, dict) else None except Exception: entry_order_id = None break else: logger.info(f"{symbol} [智能入场] 限价超时,但偏离{drift_ratio*100:.2f}%>{max_drift_ratio*100:.2f}%,取消并放弃本次交易") try: await self.client.cancel_order(symbol, int(entry_order_id)) except Exception: pass self._pending_entry_orders.pop(symbol, None) return None # 震荡/不允许市价兜底:尝试追价(减小 offset -> 更靠近当前价),但不突破追价上限 try: await self.client.cancel_order(symbol, int(entry_order_id)) except Exception: pass # offset 逐步减少到 0(越追越接近当前价) step_ratio = (step + 1) / max(1, chase_steps) cur_offset_ratio = max(0.0, limit_offset_ratio * (1.0 - step_ratio)) desired = self._calc_limit_entry_price(cur2, side, cur_offset_ratio) if side == "BUY": cap = initial_limit * (1 + max_drift_ratio) desired = min(desired, cap) else: cap = initial_limit * (1 - max_drift_ratio) desired = max(desired, cap) if desired <= 0: self._pending_entry_orders.pop(symbol, None) return None logger.info( f"{symbol} [智能入场] 追价 step={step+1}/{chase_steps} | 当前价={cur2:.6f} | " f"offset={cur_offset_ratio*100:.3f}% -> 限价={desired:.6f} | 偏离={drift_ratio*100:.2f}%" ) order = await self.client.place_order( symbol=symbol, side=side, quantity=quantity, order_type="LIMIT", price=desired, new_client_order_id=client_order_id ) if not order: if pending_trade_id and DB_AVAILABLE and Trade: try: ok = Trade.update_status(pending_trade_id, "cancelled") if not ok: logger.error(f"[DB] {symbol} 追价下单失败后更新 pending 为 cancelled 失败 trade_id={pending_trade_id}") except Exception as ex: logger.error(f"[DB] {symbol} 追价下单失败后更新 pending 为 cancelled 异常 trade_id={pending_trade_id} error={ex}") self._pending_entry_orders.pop(symbol, None) return None entry_order_id = order.get("orderId") if entry_order_id: self._pending_entry_orders[symbol] = {"order_id": entry_order_id, "created_at_ms": int(time.time() * 1000)} # 如果是市价兜底或最终限价成交,这里统一继续后续流程(下面会再查实际成交) # ===== 统一处理:确认订单成交并获取实际成交价/数量 ===== if order: if not entry_order_id: entry_order_id = order.get("orderId") if entry_order_id: logger.info(f"{symbol} [开仓] 币安订单号: {entry_order_id}") # 等待订单成交,检查订单状态并获取实际成交价格 # 只有在订单真正成交(FILLED)后才保存到数据库 if entry_order_id: # 智能入场的限价订单可能需要更长等待,这里给一个总等待兜底(默认 30s) confirm_timeout = int(config.TRADING_CONFIG.get("ENTRY_CONFIRM_TIMEOUT_SEC", 30) or 30) res = await self._wait_for_order_filled(symbol, int(entry_order_id), timeout_sec=confirm_timeout, poll_sec=1.0) order_status = res.get("status") if res.get("ok"): actual_entry_price = float(res.get("avg_price") or 0) filled_quantity = float(res.get("executed_qty") or 0) else: # 未成交(NEW/超时/CANCELED 等):撤单。撤单后校验是否已成交(竞态:撤单瞬间订单可能已成交) logger.warning(f"{symbol} [开仓] 未成交,状态: {order_status},撤销挂单") try: await self.client.cancel_order(symbol, int(entry_order_id)) except Exception: pass # 正向流程加固:撤单后校验,若已成交则继续流程 recheck = await self._wait_for_order_filled(symbol, int(entry_order_id), timeout_sec=5, poll_sec=0.5) if recheck.get("ok"): actual_entry_price = float(recheck.get("avg_price") or 0) filled_quantity = float(recheck.get("executed_qty") or 0) logger.info(f"{symbol} [开仓] 撤单后发现已成交,继续完善记录 (成交价={actual_entry_price:.4f} 数量={filled_quantity:.4f})") else: self._pending_entry_orders.pop(symbol, None) return None if not actual_entry_price or actual_entry_price <= 0: logger.error(f"{symbol} [开仓] ❌ 无法获取实际成交价格,不保存到数据库") self._pending_entry_orders.pop(symbol, None) return None if filled_quantity <= 0: logger.error(f"{symbol} [开仓] ❌ 成交数量为0,不保存到数据库") self._pending_entry_orders.pop(symbol, None) return None # 使用实际成交价格和数量 original_entry_price = entry_price entry_price = actual_entry_price quantity = filled_quantity # 使用实际成交数量 logger.info(f"{symbol} [开仓] ✓ 使用实际成交价格: {entry_price:.4f} USDT (下单时价格: {original_entry_price:.4f}), 成交数量: {quantity:.4f}") # 成交后清理 pending self._pending_entry_orders.pop(symbol, None) # ===== 成交后基于“实际成交价/数量”重新计算止损止盈(修复限价/滑点导致的偏差)===== stop_loss_pct_margin = config.TRADING_CONFIG.get('STOP_LOSS_PERCENT', 0.03) # ⚠️ 关键修复:配置值格式转换(兼容百分比形式和比例形式) if stop_loss_pct_margin is not None and stop_loss_pct_margin > 1: stop_loss_pct_margin = stop_loss_pct_margin / 100.0 stop_loss_price = self.risk_manager.get_stop_loss_price( entry_price, side, quantity, leverage, stop_loss_pct=stop_loss_pct_margin, klines=klines, bollinger=bollinger, atr=atr ) # ⚠️ 2026-01-29优化:计算止损距离用于盈亏比止盈计算(确保达到3:1目标) # 实际止损距离(用于 TP1 最小盈亏比,必须与真实挂单一致,避免 SL 被保证金封顶后 TP 仍按 ATR 拉很远) actual_stop_distance = (entry_price - stop_loss_price) if side == 'BUY' else (stop_loss_price - entry_price) stop_distance_for_tp = actual_stop_distance # 如果ATR可用,使用ATR计算更准确的止损距离(仅用于 get_take_profit_price 的盈亏比,不用于 TP1 的 MIN_RR_FOR_TP1) if atr is not None and atr > 0 and entry_price > 0: atr_percent = atr / entry_price atr_multiplier = config.TRADING_CONFIG.get('ATR_STOP_LOSS_MULTIPLIER', 3.0) # 2026-02-12优化:默认3.0,避免噪音止损 stop_distance_for_tp = entry_price * atr_percent * atr_multiplier take_profit_pct_margin = config.TRADING_CONFIG.get('TAKE_PROFIT_PERCENT', 0.30) # ⚠️ 关键修复:配置值格式转换(兼容百分比形式和比例形式) if take_profit_pct_margin is not None and take_profit_pct_margin > 1: take_profit_pct_margin = take_profit_pct_margin / 100.0 if take_profit_pct_margin is None or take_profit_pct_margin == 0: take_profit_pct_margin = float(stop_loss_pct_margin or 0) * 3.0 take_profit_price = self.risk_manager.get_take_profit_price( entry_price, side, quantity, leverage, take_profit_pct=take_profit_pct_margin, atr=atr, stop_distance=stop_distance_for_tp ) # 分步止盈(基于“实际成交价 + 已计算的止损/止盈”) # 交易规模:名义/保证金(用于统计总交易量与UI展示) try: notional_usdt = float(entry_price) * float(quantity) except Exception: notional_usdt = None try: margin_usdt = (float(notional_usdt) / float(leverage)) if notional_usdt is not None and float(leverage) > 0 else notional_usdt except Exception: margin_usdt = None # 分步止盈(基于"实际成交价 + 已计算的止损/止盈") # ⚠️ 第一目标使用 TAKE_PROFIT_1_PERCENT(默认15%),与第二目标 TAKE_PROFIT_PERCENT 分离,提高整体盈亏比 # 第一目标和触发条件必须一致,都使用 TAKE_PROFIT_1_PERCENT take_profit_1_pct_margin = config.TRADING_CONFIG.get('TAKE_PROFIT_1_PERCENT', 0.20) # 兼容百分比形式和比例形式 if take_profit_1_pct_margin is not None and take_profit_1_pct_margin > 1: take_profit_1_pct_margin = take_profit_1_pct_margin / 100.0 # 计算基于保证金的止盈距离 tp1_distance = 0.0 if margin_usdt and margin_usdt > 0 and quantity > 0: take_profit_1_amount = margin_usdt * take_profit_1_pct_margin tp1_distance = take_profit_1_amount / quantity # ⚠️ 2026-02-10优化:确保TP1至少有 1.2倍 的盈亏比 (相对于【实际】止损距离) # 使用 actual_stop_distance(真实挂单距离),避免 SL 被保证金封顶后仍用 ATR 宽距离推高 TP 导致 4% SL vs 62% TP 的畸形 if actual_stop_distance is not None and actual_stop_distance > 0: min_rr_for_tp1 = config.TRADING_CONFIG.get('MIN_RR_FOR_TP1', 1.5) min_tp1_distance = actual_stop_distance * min_rr_for_tp1 if min_tp1_distance > tp1_distance: logger.info(f"{symbol} [优化] TP1距离 ({tp1_distance:.4f}) 小于 {min_rr_for_tp1}倍实际止损距离 ({min_tp1_distance:.4f}),已自动调整以保证盈亏比") tp1_distance = min_tp1_distance # ⚠️ 2026-02-12优化:确保TP1至少覆盖双向手续费(避免微利单实际上亏损) # 手续费率估算:0.05% (Taker) * 2 (双向) * 1.5 (安全系数) = 0.15% 价格变动 min_fee_distance = entry_price * 0.0015 if min_fee_distance > tp1_distance: logger.info(f"{symbol} [优化] TP1距离 ({tp1_distance:.4f}) 小于 手续费磨损距离 ({min_fee_distance:.4f}),已自动调整以覆盖成本") tp1_distance = min_fee_distance # ⚠️ 止盈上限:TP1 不超过配置的止盈比例,避免“4% 止损 vs 62% 止盈”的畸形(与 USE_MARGIN_CAP_FOR_TP 一致) if margin_usdt and margin_usdt > 0 and quantity > 0: tp1_max_pct = config.TRADING_CONFIG.get('TAKE_PROFIT_1_PERCENT', 0.20) if tp1_max_pct is not None and tp1_max_pct > 1: tp1_max_pct = tp1_max_pct / 100.0 tp1_max_pct = (tp1_max_pct or 0.20) * 1.5 # 允许第一目标最多约 30% 保证金 tp1_max_amount = margin_usdt * tp1_max_pct tp1_max_distance = tp1_max_amount / quantity if tp1_distance > tp1_max_distance: logger.info(f"{symbol} [优化] TP1距离 ({tp1_distance:.4f}) 超过止盈上限 ({tp1_max_distance:.4f},约{tp1_max_pct*100:.0f}% margin),已封顶") tp1_distance = tp1_max_distance if tp1_distance > 0: if side == 'BUY': take_profit_1 = entry_price + tp1_distance else: take_profit_1 = entry_price - tp1_distance else: if side == 'BUY': take_profit_1 = entry_price + (entry_price - stop_loss_price) else: take_profit_1 = entry_price - (stop_loss_price - entry_price) take_profit_2 = take_profit_price if take_profit_1 is not None and take_profit_2 is not None: if side == 'BUY': if take_profit_1 >= take_profit_2 and take_profit_2 > entry_price: closer = min(take_profit_1, take_profit_2) further = max(take_profit_1, take_profit_2) if closer > entry_price: take_profit_1 = closer take_profit_2 = further else: if take_profit_1 <= take_profit_2 and take_profit_2 < entry_price: closer = max(take_profit_1, take_profit_2) further = min(take_profit_1, take_profit_2) if closer < entry_price: take_profit_1 = closer take_profit_2 = further # 记录到数据库:有 pending 则完善(WS 也会按 client_order_id 完善),否则新建 trade_id = None if DB_AVAILABLE and Trade: try: if client_order_id: row = Trade.get_by_client_order_id(client_order_id, self.account_id) if row and str(row.get("status")) == "pending": ok = Trade.update_pending_to_filled( client_order_id, self.account_id, entry_order_id, entry_price, quantity ) if ok: row = Trade.get_by_client_order_id(client_order_id, self.account_id) trade_id = row.get("id") if row else None if trade_id: try: Trade.update_open_fields( trade_id, stop_loss_price=stop_loss_price, take_profit_price=take_profit_price, take_profit_1=take_profit_1, take_profit_2=take_profit_2, notional_usdt=notional_usdt, margin_usdt=margin_usdt, entry_context=entry_context, atr=atr, ) logger.info(f"[DB] {symbol} 已完善 pending→open (ID: {trade_id}, orderId: {entry_order_id}, 成交价: {entry_price:.4f}, 数量: {quantity:.4f})") except Exception as of_ex: logger.error(f"[DB] {symbol} 完善 open 字段失败 trade_id={trade_id} (SL/TP/notional 等未写入): {of_ex}") else: logger.error( f"[DB] {symbol} 完善 pending→open 失败,将走新建兜底或依赖补单: " f"client_order_id={client_order_id!r} entry_order_id={entry_order_id}" ) elif row and str(row.get("status")) != "pending": logger.debug(f"[DB] {symbol} 已有非 pending 记录 status={row.get('status')},将新建或跳过") if trade_id is None: # 无 pending 或未匹配到:走新建(兜底) fallback_client_order_id = (order.get("clientOrderId") if order else None) or client_order_id logger.info(f"[DB] {symbol} 无 pending 记录,新建 open 记录 client_order_id={fallback_client_order_id!r} entry_order_id={entry_order_id}") # 如果 REST 已获取到 entry_order_id,直接写入;否则留空,等待 WS 推送或后续同步补全 trade_id = Trade.create( symbol=symbol, side=side, quantity=quantity, entry_price=entry_price, leverage=leverage, entry_reason=entry_reason, entry_order_id=entry_order_id, # REST 已获取则直接写入 client_order_id=fallback_client_order_id, stop_loss_price=stop_loss_price, take_profit_price=take_profit_price, take_profit_1=take_profit_1, take_profit_2=take_profit_2, atr=atr, notional_usdt=notional_usdt, margin_usdt=margin_usdt, entry_context=entry_context, account_id=self.account_id, ) if entry_order_id: logger.info(f"✓ {symbol} 交易记录已保存到数据库 (ID: {trade_id}, 订单号: {entry_order_id}, 成交价: {entry_price:.4f}, 成交数量: {quantity:.4f})") else: logger.warning(f"⚠ {symbol} 交易记录已保存但 entry_order_id 为空 (ID: {trade_id}),等待 WS 推送或后续同步补全") # 如果有 client_order_id,尝试通过 REST 查询订单号补全 if fallback_client_order_id: try: # 延迟查询,确保订单已入库 await asyncio.sleep(1) orders = await self.client.client.futures_get_all_orders( symbol=symbol, limit=10, recvWindow=10000 ) for o in orders or []: if str(o.get("clientOrderId", "")).strip() == fallback_client_order_id: found_order_id = o.get("orderId") if found_order_id: Trade.update_entry_order_id(trade_id, found_order_id) logger.info(f"✓ {symbol} 已通过 REST 补全 entry_order_id: {found_order_id}") break except Exception as e: logger.warning(f"[DB] {symbol} REST 补全 entry_order_id 失败 trade_id={trade_id}: {e}") except Exception as e: logger.error(f"❌ 保存交易记录到数据库失败: {e}") logger.error(f" 错误类型: {type(e).__name__}") import traceback logger.error(f" 错误详情:\n{traceback.format_exc()}") _log_trade_db_failure( symbol=symbol, entry_order_id=entry_order_id, side=side, quantity=quantity, entry_price=entry_price, account_id=self.account_id, reason="create_exception", error_type=type(e).__name__, error_message=str(e) ) return None elif not DB_AVAILABLE: logger.error(f"[DB] 数据库不可用,无法保存成交记录 symbol={symbol} entry_order_id={entry_order_id} client_order_id={client_order_id!r},将依赖补单") _log_trade_db_failure( symbol=symbol, entry_order_id=entry_order_id, side=side, quantity=quantity, entry_price=entry_price, account_id=self.account_id, reason="DB_AVAILABLE=False" ) elif not Trade: logger.error(f"[DB] Trade 模型未导入,无法保存成交记录 symbol={symbol} entry_order_id={entry_order_id},将依赖补单") _log_trade_db_failure( symbol=symbol, entry_order_id=entry_order_id, side=side, quantity=quantity, entry_price=entry_price, account_id=self.account_id, reason="Trade=None" ) # 记录持仓信息(包含动态止损止盈和分步止盈) from datetime import datetime position_info = { 'symbol': symbol, 'side': side, 'quantity': quantity, 'entryPrice': entry_price, 'changePercent': change_percent, 'orderId': order.get('orderId'), 'tradeId': trade_id, # 数据库交易ID 'stopLoss': stop_loss_price, 'takeProfit': take_profit_price, # 保留原始止盈价(第二目标) 'takeProfit1': take_profit_1, # 第一目标(盈亏比1:1,了结50%) 'takeProfit2': take_profit_2, # 第二目标(原始止盈价,剩余50%) 'partialProfitTaken': False, # 是否已部分止盈 'remainingQuantity': quantity, # 剩余仓位数量 'initialStopLoss': stop_loss_price, # 初始止损(用于移动止损) 'leverage': leverage, 'entryReason': entry_reason, 'entryTime': get_beijing_time(), # 记录入场时间(使用北京时间,用于计算持仓持续时间) 'strategyType': 'trend_following', # 策略类型(简化后只有趋势跟踪) 'atr': atr, 'maxProfit': 0.0, # 记录最大盈利(用于移动止损/滞涨判断) 'lastNewHighTs': None, # 最后一次创新高的时间戳(秒),用于滞涨早止盈 'stagnationExitTriggered': False, # 是否已执行滞涨分批减仓+抬止损(只执行一次) 'trailingStopActivated': False, # 移动止损是否已激活 'breakevenStopSet': False, # 是否已执行“盈利达阈值后移至保本” 'account_id': self.account_id } self.active_positions[symbol] = position_info logger.info( f"开仓成功: {symbol} {side} {quantity} @ {entry_price:.4f} " f"(涨跌幅: {change_percent:.2f}%)" ) # 验证持仓是否真的在币安存在 try: await asyncio.sleep(0.5) # 等待一小段时间让币安更新持仓 positions = await self._get_open_positions() binance_position = next( (p for p in positions if p['symbol'] == symbol and float(p.get('positionAmt', 0)) != 0), None ) if binance_position: logger.info( f"{symbol} [开仓验证] ✓ 币安持仓确认: " 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} [开仓验证] ⚠️ 币安账户中没有持仓,可能订单未成交或被立即平仓" ) # 清理本地记录 if symbol in self.active_positions: del self.active_positions[symbol] # 如果数据库已保存,标记为取消 if trade_id and DB_AVAILABLE and Trade: try: from database.connection import db db.execute_update( "UPDATE trades SET status = 'cancelled' WHERE id = %s", (trade_id,) ) logger.info(f"{symbol} [开仓验证] 已更新数据库状态为 cancelled (ID: {trade_id})") except Exception as e: logger.warning(f"{symbol} [开仓验证] 更新数据库状态失败: {e}") return None except Exception as verify_error: logger.warning(f"{symbol} [开仓验证] 验证持仓时出错: {verify_error},继续使用本地记录") # 启动WebSocket实时监控 if self._monitoring_enabled: await self._start_position_monitoring(symbol) # 记录“今日开仓次数”(用于用户风控旋钮 MAX_DAILY_ENTRIES) try: await self.risk_manager.record_entry(symbol) except Exception: pass return position_info return None except Exception as e: err_msg = str(e).strip() if str(e) else f"{type(e).__name__}(无详情)" logger.error(f"开仓失败 {symbol}: {err_msg}", exc_info=True) return None async def close_position(self, symbol: str, reason: str = 'manual') -> bool: """ 平仓 Args: symbol: 交易对 reason: 平仓原因(manual, stop_loss, take_profit, trailing_stop, sync, take_profit_partial_then_take_profit, take_profit_partial_then_stop, take_profit_partial_then_trailing_stop) Returns: 是否成功 """ 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.futures_cancel_algo_order(int(oid)) except Exception: pass info0.pop("exchangeSlOrderId", None) info0.pop("exchangeTpOrderId", None) except Exception: pass # 获取当前持仓 positions = await self._get_open_positions() position = next( (p for p in positions if p['symbol'] == symbol), None ) if not position: logger.warning(f"{symbol} [平仓] 币安账户中没有持仓,可能已被平仓") # 即使币安没有持仓,也要更新数据库状态 updated = False if DB_AVAILABLE and Trade and symbol in self.active_positions: position_info = self.active_positions[symbol] trade_id = position_info.get('tradeId') if trade_id: logger.info(f"{symbol} [平仓] 更新数据库状态为已平仓 (ID: {trade_id})...") ticker = await self.client.get_ticker_24h(symbol) exit_price = float(ticker['price']) if ticker else float(position_info['entryPrice']) entry_price = float(position_info['entryPrice']) quantity = float(position_info['quantity']) if position_info['side'] == 'BUY': pnl = (exit_price - entry_price) * quantity pnl_percent = ((exit_price - entry_price) / entry_price) * 100 else: pnl = (entry_price - exit_price) * quantity pnl_percent = ((entry_price - exit_price) / entry_price) * 100 entry_time = position_info.get('entryTime') duration_minutes = None if entry_time: try: if isinstance(entry_time, str): entry_dt = datetime.strptime(entry_time, '%Y-%m-%d %H:%M:%S') else: entry_dt = entry_time exit_dt = get_beijing_time() duration = exit_dt - entry_dt duration_minutes = int(duration.total_seconds() / 60) except Exception as e: logger.debug(f"计算持仓持续时间失败: {e}") strategy_type = position_info.get('strategyType', 'trend_following') db_update_retries = 3 for db_attempt in range(db_update_retries): try: Trade.update_exit( trade_id=trade_id, exit_price=exit_price, exit_reason=reason, pnl=pnl, pnl_percent=pnl_percent, exit_order_id=None, strategy_type=strategy_type, duration_minutes=duration_minutes ) logger.info(f"{symbol} [平仓] ✓ 数据库状态已更新") updated = True break except Exception as e: err_msg = str(e).strip() or f"{type(e).__name__}" if db_attempt < db_update_retries - 1: wait_sec = 2 logger.warning( f"{symbol} [平仓] 更新数据库失败 (第 {db_attempt + 1}/{db_update_retries} 次): {err_msg}," f"{wait_sec}秒后重试" ) await asyncio.sleep(wait_sec) else: logger.error(f"{symbol} [平仓] ❌ 更新数据库状态失败: {err_msg}") # 清理本地记录 await self._stop_position_monitoring(symbol) if symbol in self.active_positions: del self.active_positions[symbol] # 如果更新了数据库,返回成功;否则返回失败 return updated # 确定平仓方向(与开仓相反) position_amt = position['positionAmt'] side = 'SELL' if position_amt > 0 else 'BUY' quantity = abs(position_amt) position_side = 'LONG' if position_amt > 0 else 'SHORT' # 二次校验:用币安实时持仓数量兜底,避免 reduceOnly 被拒绝(-2022) live_amt = await self._get_live_position_amt(symbol, position_side=position_side) if live_amt is None or abs(live_amt) <= 0: logger.warning(f"{symbol} [平仓] 实时查询到持仓已为0,跳过下单并按已平仓处理") # 复用“币安无持仓”的处理逻辑:走上面的分支 position = None # 触发上方逻辑:直接返回 updated/清理 # 这里简单调用同步函数路径 ticker = await self.client.get_ticker_24h(symbol) exit_price = float(ticker['price']) if ticker else float(position.get('entryPrice', 0) if position else 0) await self._stop_position_monitoring(symbol) if symbol in self.active_positions: del self.active_positions[symbol] logger.info(f"{symbol} [平仓] 已清理本地记录(币安无持仓)") return True # 以币安实时持仓数量为准(并向下截断到不超过持仓) quantity = min(quantity, abs(live_amt)) quantity = await self._adjust_close_quantity(symbol, quantity) if quantity <= 0: logger.warning(f"{symbol} [平仓] 数量调整后为0,跳过下单并清理本地记录") await self._stop_position_monitoring(symbol) if symbol in self.active_positions: del self.active_positions[symbol] return True logger.info( f"{symbol} [平仓] 下单信息: {side} {quantity:.4f} @ MARKET " f"(持仓数量: {position_amt:.4f})" ) # 平仓(使用 reduceOnly=True 确保只减少持仓,不增加反向持仓) try: logger.debug(f"{symbol} [平仓] 调用 place_order: {side} {quantity:.4f} @ MARKET (reduceOnly=True)") order = await self.client.place_order( symbol=symbol, side=side, quantity=quantity, order_type='MARKET', reduce_only=True, # 平仓时使用 reduceOnly=True position_side=position_side, # 兼容对冲模式(Hedge):必须指定 LONG/SHORT ) logger.debug(f"{symbol} [平仓] place_order 返回: {order}") except Exception as order_error: logger.error(f"{symbol} [平仓] ❌ 下单失败: {order_error}") logger.error(f" 下单参数: symbol={symbol}, side={side}, quantity={quantity:.4f}, order_type=MARKET") logger.error(f" 错误类型: {type(order_error).__name__}") import traceback logger.error(f" 完整错误堆栈:\n{traceback.format_exc()}") raise # 重新抛出异常,让外层捕获 if order: order_id = order.get('orderId') logger.info(f"{symbol} [平仓] ✓ 平仓订单已提交 (订单ID: {order_id}),DB 由 WS ORDER_TRADE_UPDATE 更新") # 支付式闭环:平仓数据仅由 WS 推送更新,此处只做本地清理 await self._stop_position_monitoring(symbol) if symbol in self.active_positions: del self.active_positions[symbol] logger.info(f"{symbol} [平仓] ✓ 平仓完成: {side} {quantity:.4f} (原因: {reason})") return True else: # place_order 返回 None:可能是 -2022(ReduceOnly rejected)等竞态场景 # 兜底:再查一次实时持仓,如果已经为0,则当作“已平仓”处理,避免刷屏与误判失败 try: live2 = await self._get_live_position_amt(symbol, position_side=position_side) except Exception: live2 = None if live2 is None or abs(live2) <= 0: logger.warning(f"{symbol} [平仓] 下单返回None,但实时持仓已为0,按已平仓处理(可能竞态/手动平仓)") await self._stop_position_monitoring(symbol) if symbol in self.active_positions: del self.active_positions[symbol] return True logger.error(f"{symbol} [平仓] ❌ 下单返回 None(实时持仓仍存在: {live2}),可能的原因:") logger.error(f" 1. ReduceOnly 被拒绝(-2022)但持仓未同步") logger.error(f" 2. 数量精度调整后为 0 或负数") logger.error(f" 3. 无法获取价格信息") logger.error(f" 4. 其他下单错误(已在 place_order 中记录)") logger.error(f" 持仓信息: {side} {quantity:.4f} @ MARKET") # 尝试获取更多诊断信息 try: symbol_info = await self.client.get_symbol_info(symbol) ticker = await self.client.get_ticker_24h(symbol) if ticker: current_price = ticker['price'] notional_value = quantity * current_price min_notional = symbol_info.get('minNotional', 5.0) if symbol_info else 5.0 logger.error(f" 当前价格: {current_price:.4f} USDT") logger.error(f" 订单名义价值: {notional_value:.2f} USDT") logger.error(f" 最小名义价值: {min_notional:.2f} USDT") if notional_value < min_notional: logger.error(f" ⚠ 订单名义价值不足,无法平仓") except Exception as diag_error: logger.warning(f" 无法获取诊断信息: {diag_error}") return False except Exception as e: logger.error(f"{symbol} [平仓] ❌ 平仓失败: {e}") logger.error(f" 错误类型: {type(e).__name__}") import traceback logger.error(f" 完整错误堆栈:\n{traceback.format_exc()}") # 关键:平仓失败时不要盲目清理本地持仓/停止监控,否则会导致“仍有仓位但不再监控/不再自动止损止盈” # 仅当确认币安已无持仓(或实时持仓为0)时,才清理本地记录。 try: amt0 = await self._get_live_position_amt(symbol, position_side=None) except Exception: amt0 = None if amt0 is not None and abs(amt0) <= 0: try: await self._stop_position_monitoring(symbol) except Exception: pass try: if symbol in self.active_positions: del self.active_positions[symbol] except Exception: pass logger.warning(f"{symbol} [平仓] 异常后检查:币安持仓已为0,已清理本地记录") else: logger.warning( f"{symbol} [平仓] 异常后检查:币安持仓仍可能存在(amt={amt0}),保留本地记录与监控,等待下次同步/重试" ) return False async def _get_live_position_amt(self, symbol: str, position_side: Optional[str] = None) -> Optional[float]: """ 从币安原始接口读取持仓数量(避免本地状态/缓存不一致导致 reduceOnly 被拒绝)。 - 单向模式:通常只有一个 net 持仓 - 对冲模式:可能同时有 LONG/SHORT,两条腿用 positionSide 区分 """ try: if not getattr(self.client, "client", None): return None res = await self.client.client.futures_position_information(symbol=symbol, recvWindow=20000) if not isinstance(res, list): return None ps = (position_side or "").upper() nonzero = [] for p in res: if not isinstance(p, dict): continue try: amt = float(p.get("positionAmt", 0)) except Exception: continue if abs(amt) <= 0: continue nonzero.append((amt, p)) if not nonzero: return 0.0 if ps in ("LONG", "SHORT"): for amt, p in nonzero: pps = (p.get("positionSide") or "").upper() if pps == ps: return amt # 如果没匹配到 positionSide,退化为按符号推断 if ps == "LONG": cand = next((amt for amt, _ in nonzero if amt > 0), None) return cand if cand is not None else 0.0 if ps == "SHORT": cand = next((amt for amt, _ in nonzero if amt < 0), None) return cand if cand is not None else 0.0 # 没提供 position_side:返回净持仓(单向模式) return sum([amt for amt, _ in nonzero]) except Exception as e: logger.debug(f"{symbol} 读取实时持仓失败: {e}") return None async def _adjust_close_quantity(self, symbol: str, quantity: float) -> float: """ 平仓数量调整:只允许向下取整(避免超过持仓导致 reduceOnly 被拒绝)。 """ try: symbol_info = await self.client.get_symbol_info(symbol) except Exception: symbol_info = None try: q = float(quantity) except Exception: return 0.0 if not symbol_info: return max(0.0, q) try: step_size = float(symbol_info.get("stepSize", 0) or 0) except Exception: step_size = 0.0 qty_precision = int(symbol_info.get("quantityPrecision", 8) or 8) if step_size and step_size > 0: q = float(int(q / step_size)) * step_size else: q = round(q, qty_precision) q = round(q, qty_precision) return max(0.0, q) async def _symbol_has_sltp_orders(self, symbol: str) -> bool: """该交易对在交易所是否存在止损/止盈类条件单(用于识别「可能为系统单」的持仓)。""" try: for o in (await self.client.get_open_orders(symbol)) or []: t = str(o.get("type") or "").upper() if t in ("STOP_MARKET", "TAKE_PROFIT_MARKET", "STOP", "TAKE_PROFIT"): return True for o in (await self.client.futures_get_open_algo_orders(symbol, algo_type="CONDITIONAL")) or []: t = str(o.get("orderType") or o.get("type") or "").upper() if t in ("STOP_MARKET", "TAKE_PROFIT_MARKET", "STOP", "TAKE_PROFIT"): return True except Exception: pass return False async def _get_sltp_from_exchange(self, symbol: str, side: str) -> tuple: """从币安条件单读取当前止损价、止盈价,便于重启后不覆盖已有保护。(stop_price, take_profit_price),缺的为 None。""" sl_price, tp_price = None, None try: for o in (await self.client.get_open_orders(symbol)) or []: t = str(o.get("type") or "").upper() sp = o.get("stopPrice") or o.get("triggerPrice") try: p = float(sp) if sp is not None else None except (TypeError, ValueError): p = None if p is None: continue if t in ("STOP_MARKET", "STOP"): sl_price = p elif t in ("TAKE_PROFIT_MARKET", "TAKE_PROFIT"): tp_price = p # 条件单多在 CONDITIONAL 里 for o in (await self.client.futures_get_open_algo_orders(symbol, algo_type="CONDITIONAL")) or []: t = str(o.get("orderType") or o.get("type") or "").upper() sp = o.get("stopPrice") or o.get("triggerPrice") or o.get("tp") try: p = float(sp) if sp is not None else None except (TypeError, ValueError): p = None if p is None: continue if t in ("STOP_MARKET", "STOP"): sl_price = p elif t in ("TAKE_PROFIT_MARKET", "TAKE_PROFIT"): tp_price = p except Exception as e: logger.debug(f"{symbol} 从交易所读取止损/止盈失败: {e}") return (sl_price, tp_price) def _breakeven_stop_price(self, entry_price: float, side: str, fee_buffer_pct: Optional[float] = None) -> float: """含手续费的保本止损价:做多=入场*(1+费率),做空=入场*(1-费率),平仓后不亏。""" if fee_buffer_pct is None: fee_buffer_pct = float(config.TRADING_CONFIG.get("FEE_BUFFER_PCT", 0.0015) or 0.0015) if fee_buffer_pct > 0.01: fee_buffer_pct = fee_buffer_pct / 100.0 if side == "BUY": return entry_price * (1 + fee_buffer_pct) return entry_price * (1 - fee_buffer_pct) def _min_protect_amount_for_fees(self, margin: float, leverage: float) -> float: """移动止损时至少保护的金额(覆盖开平双向手续费,避免“保护”后仍为负)。""" lev = max(float(leverage or 10), 1) # 双向约 0.05%*2=0.1% 名义 → 保证金上约 0.1%*leverage return margin * (0.001 * lev) def _stop_price_to_lock_pct( self, entry_price: float, side: str, margin: float, quantity: float, lock_pct: float ) -> float: """计算「锁住 lock_pct% 保证金利润」对应的止损价。lock_pct 为百分比数值,如 5 表示 5%。""" if margin <= 0 or quantity <= 0: return self._breakeven_stop_price(entry_price, side) lock_ratio = float(lock_pct) / 100.0 if lock_ratio > 1: lock_ratio = lock_ratio / 100.0 lock_amount = margin * lock_ratio if side == 'BUY': return entry_price + lock_amount / quantity return entry_price - lock_amount / quantity async def _ensure_exchange_sltp_orders(self, symbol: str, position_info: Dict, current_price: Optional[float] = None) -> None: """ 在币安侧挂止损/止盈保护单(STOP_MARKET + TAKE_PROFIT_MARKET)。 目的: - 服务重启/网络波动时仍有交易所级别保护 - 用户在币安界面能看到止损/止盈委托 流程(与 docs/bian 条件订单交易更新推送 一致): - 先检查持仓存在 → 取消旧保护单 → 再次检查持仓(避免条件单已触发平仓后仍挂单导致 -4509/超时)→ 再挂 SL/TP。 - 条件单触发平仓后,交易所会将同 symbol 其余条件单置为 EXPIRED,见 ALGO_UPDATE。 """ 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 # 用于 ALGO_UPDATE 时按 entry_order_id 精确匹配 DB 平仓记录 entry_order_id = position_info.get("orderId") or position_info.get("entry_order_id") client_algo_id_sl = None client_algo_id_tp = None if entry_order_id: eid = str(entry_order_id).strip() if eid and len(eid) <= 30: # 留足 SL_/TP_ 前缀 client_algo_id_sl = f"SL_{eid}"[:36] client_algo_id_tp = f"TP_{eid}"[:36] 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: logger.warning(f"[账号{self.account_id}] {symbol} 同步跳过: 止损或止盈为空 stop_loss={stop_loss} take_profit={take_profit}") return sync_type = "保本/移动止损同步" if (position_info.get("breakevenStopSet") or position_info.get("trailingStopActivated")) else "SL/TP同步" logger.info( f"[账号{self.account_id}] {symbol} 开始同步止损/止盈至交易所 [{sync_type}]: " f"止损触发价={float(stop_loss):.4f} 止盈触发价={float(take_profit):.4f}" ) # 验证止损价格是否合理。保本/移动止损时:多单止损可≥入场价、空单止损可≤入场价,不得被改回亏损价 entry_price = position_info.get("entryPrice") if entry_price: try: entry_price_val = float(entry_price) stop_loss_val = float(stop_loss) min_gap_pct = 0.005 # 最小 0.5% 距离,与 risk_manager 一致 # 多单止损≥入场价 = 保本/移动止损,有效;空单止损≤入场价 = 保本/移动止损,有效 if side == "BUY" and stop_loss_val >= entry_price_val: # 保本或移动止损,保留不修正 pass elif side == "SELL" and stop_loss_val <= entry_price_val: pass elif side == "BUY" and stop_loss_val < entry_price_val: if stop_loss_val > entry_price_val * (1 - min_gap_pct): safe_sl = entry_price_val * (1 - min_gap_pct) logger.warning( f"{symbol} 止损价({stop_loss_val:.8f})距入场过近(BUY),已修正为 {safe_sl:.8f}" ) stop_loss = safe_sl position_info["stopLoss"] = stop_loss elif side == "SELL" and stop_loss_val > entry_price_val: if stop_loss_val < entry_price_val * (1 + min_gap_pct): safe_sl = entry_price_val * (1 + min_gap_pct) logger.warning( f"{symbol} 止损价({stop_loss_val:.8f})距入场过近(SELL),已修正为 {safe_sl:.8f}" ) stop_loss = safe_sl position_info["stopLoss"] = stop_loss except Exception as e: logger.warning(f"{symbol} 验证止损价格时出错: {e}") # ⚠️ 关键修复:挂单前先检查持仓是否真的存在,避免 -4509(没有持仓)和 -4061(positionSide 不匹配) try: positions = await self._get_open_positions() live_position = next((p for p in positions if p.get('symbol') == symbol), None) if not live_position: logger.warning(f"{symbol} ⚠️ 持仓已不存在(可能已平仓),跳过挂止损/止盈单,避免 -4509/-4061 错误") return position_amt = float(live_position.get('positionAmt', 0) or 0) if abs(position_amt) <= 0: logger.warning(f"{symbol} ⚠️ 持仓数量为0,跳过挂止损/止盈单") return # 验证持仓方向是否匹配(对冲模式下需要检查 positionSide) live_side = "BUY" if position_amt > 0 else "SELL" if live_side != side: logger.warning(f"{symbol} ⚠️ 持仓方向不匹配(本地记录: {side}, 实际: {live_side}),跳过挂单,避免 -4061") return except Exception as e: logger.warning(f"{symbol} 检查持仓存在性时出错,继续尝试挂单(可能误报): {e}") # 防重复:先取消旧的保护单(仅取消特定类型,避免误伤普通挂单) try: await self.client.cancel_open_algo_orders_by_order_types( symbol, {"STOP_MARKET", "TAKE_PROFIT_MARKET", "TRAILING_STOP_MARKET"} ) logger.info( f"[账号{self.account_id}] {symbol} 已取消旧保护单,准备挂新单 [{sync_type}] " f"条件价: 止损={float(stop_loss):.4f} 止盈={float(take_profit):.4f}" ) if sync_type == "保本/移动止损同步": _log_trailing_stop_event( self.account_id, symbol, "sync_to_exchange", stop_loss=float(stop_loss), take_profit=float(take_profit), msg="准备挂新单" ) except Exception as e: logger.warning(f"[账号{self.account_id}] {symbol} 取消旧保护单异常: {e},继续尝试挂新单") # 取消后再次确认持仓仍存在(docs/bian:条件单触发平仓后,交易所会 EXPIRED 其他条件单;若先触发了 TP/SL 平仓,此处再挂会 -4509 或请求卡住) try: positions = await self._get_open_positions() live_position = next((p for p in positions if p.get('symbol') == symbol), None) if not live_position or abs(float(live_position.get('positionAmt', 0) or 0)) <= 0: logger.info( f"[账号{self.account_id}] {symbol} 取消旧保护单后持仓已不存在(可能已被条件单平仓),跳过挂新单" ) return except Exception as e: logger.debug(f"{symbol} 取消后复检持仓失败: {e},继续尝试挂单") # 获取当前价格(如果未提供):优先 WS 缓存(bookTicker/ticker24h)→ 持仓 markPrice → REST ticker if current_price is None: try: # 1) 优先 Redis/WS:最优挂单中点价或 24h ticker(进程内不保留全量) try: try: from .book_ticker_stream import get_book_ticker, get_book_ticker_from_redis except ImportError: from book_ticker_stream import get_book_ticker, get_book_ticker_from_redis redis_cache = getattr(self.client, "redis_cache", None) if redis_cache: book = await get_book_ticker_from_redis(redis_cache, symbol) else: book = get_book_ticker(symbol) if book and float(book.get("bidPrice", 0)) > 0 and float(book.get("askPrice", 0)) > 0: mid = (float(book["bidPrice"]) + float(book["askPrice"])) / 2 current_price = mid logger.debug(f"{symbol} 从 bookTicker 获取当前价格: {current_price}") except Exception: pass if current_price is None: try: try: from .ticker_24h_stream import ( get_tickers_24h_cache, get_tickers_24h_from_redis, is_ticker_24h_cache_fresh, ) except ImportError: from ticker_24h_stream import ( get_tickers_24h_cache, get_tickers_24h_from_redis, is_ticker_24h_cache_fresh, ) if is_ticker_24h_cache_fresh(max_age_sec=120): redis_cache = getattr(self.client, "redis_cache", None) tickers = await get_tickers_24h_from_redis(redis_cache) if redis_cache else get_tickers_24h_cache() t = tickers.get(symbol) if tickers else None if t and t.get("price"): current_price = float(t["price"]) logger.debug(f"{symbol} 从 ticker24h 获取当前价格: {current_price}") except Exception: pass # 2) 持仓标记价格(MARK_PRICE,与止损单触发基准一致) if current_price is None: positions = await self._get_open_positions() position = next((p for p in positions if p['symbol'] == symbol), None) if position: mark_price = position.get('markPrice') if mark_price and float(mark_price) > 0: current_price = float(mark_price) logger.debug(f"{symbol} 从持仓获取标记价格: {current_price}") # 3) REST:深度最优档中点(权重低)→ 24h ticker if current_price is None: depth = await self.client.get_depth(symbol, limit=5) if depth: bids = depth.get("bids") or [] asks = depth.get("asks") or [] if bids and asks and len(bids[0]) >= 1 and len(asks[0]) >= 1: try: mid = (float(bids[0][0]) + float(asks[0][0])) / 2 if mid > 0: current_price = mid logger.debug(f"{symbol} 从 depth REST 获取当前价格: {current_price}") except (ValueError, TypeError, IndexError): pass if current_price is None: ticker = await self.client.get_ticker_24h(symbol) if ticker: current_price = ticker.get('price') and float(ticker['price']) or None if current_price: logger.debug(f"{symbol} 从 ticker REST 获取当前价格: {current_price}") except Exception as e: err_msg = getattr(e, "message", str(e)) or repr(e) logger.warning(f"{symbol} 获取当前价格失败: {type(e).__name__}: {err_msg}") # 如果仍然没有当前价格,记录警告 if current_price is None: logger.warning(f"{symbol} ⚠️ 无法获取当前价格(已尝试 WS bookTicker/ticker24h、持仓、depth、REST ticker),止损单可能无法正确验证触发条件") # 在挂止损单前,检查当前价格是否已经触发止损(避免 -2021 错误) sl_failed_due_to_gte = False # GTE/持仓未就绪时仅打 WARNING,不刷 ERROR if current_price and stop_loss: try: current_price_val = float(current_price) stop_loss_val = float(stop_loss) entry_price_val = float(entry_price) if entry_price else None # 检查是否已经触发止损 if side == "BUY": # 做多:当前价 <= 止损价,说明已触发止损 if current_price_val <= stop_loss_val: entry_price_str = f"{entry_price_val:.8f}" if entry_price_val is not None else 'N/A' logger.error( f"{symbol} ⚠️ 当前价格({current_price_val:.8f})已触发止损价({stop_loss_val:.8f}),无法挂止损单,立即执行市价平仓保护!" f" | 入场价: {entry_price_str}" ) # 立即执行市价平仓 await self.close_position(symbol, reason='stop_loss') return else: 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", client_algo_id=client_algo_id_sl, ) elif side == "SELL": # 做空:当前价 >= 止损价,说明已触发止损 if current_price_val >= stop_loss_val: entry_price_str = f"{entry_price_val:.8f}" if entry_price_val is not None else 'N/A' logger.error( f"{symbol} ⚠️ 当前价格({current_price_val:.8f})已触发止损价({stop_loss_val:.8f}),无法挂止损单,立即执行市价平仓保护!" f" | 入场价: {entry_price_str}" ) # 立即执行市价平仓 await self.close_position(symbol, reason='stop_loss') return else: 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", client_algo_id=client_algo_id_sl, ) else: 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", client_algo_id=client_algo_id_sl, ) except AlgoOrderPositionUnavailableError: sl_failed_due_to_gte = True sl_order = None except Exception as e: # 检查是否是 -2021 (立即触发) error_msg = str(e) if "-2021" in error_msg or "immediately trigger" in error_msg: logger.error(f"{symbol} ⚠️ 止损单会立即触发(-2021),视为已触发止损,立即执行市价平仓") await self.close_position(symbol, reason='stop_loss') return logger.warning(f"{symbol} 检查止损触发条件时出错: {e},继续尝试挂单") try: 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", client_algo_id=client_algo_id_sl, ) except AlgoOrderPositionUnavailableError: sl_failed_due_to_gte = True sl_order = None except Exception as retry_e: retry_msg = str(retry_e) if "-2021" in retry_msg or "immediately trigger" in retry_msg: logger.error(f"{symbol} ⚠️ 重试挂止损单会立即触发(-2021),立即执行市价平仓") await self.close_position(symbol, reason='stop_loss') return logger.error(f"{symbol} 重试挂止损单失败: {retry_e}") sl_order = None else: try: 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", client_algo_id=client_algo_id_sl, ) except AlgoOrderPositionUnavailableError: sl_failed_due_to_gte = True sl_order = None except Exception as e: error_msg = str(e) if "-2021" in error_msg or "immediately trigger" in error_msg: logger.error(f"{symbol} ⚠️ 止损单会立即触发(-2021),视为已触发止损,立即执行市价平仓") await self.close_position(symbol, reason='stop_loss') return logger.error(f"{symbol} 挂止损单失败(API/网络): {e}") sl_order = None if sl_order and entry_order_id: algo_id = sl_order.get("algoId") if algo_id is not None: try: rc = getattr(self.client, "redis_cache", None) if rc: await rc.set( f"ats:algo2entry:{self.account_id}:{algo_id}", str(entry_order_id), ttl=7 * 86400, ) except Exception: pass if sl_order: logger.info( f"[账号{self.account_id}] {symbol} ✓ 止损单已成功挂到交易所: {sl_order.get('algoId', 'N/A')} " f"触发价={float(stop_loss):.4f}" ) else: if sl_failed_due_to_gte: logger.warning(f"{symbol} 条件单被拒(持仓未就绪或已平),跳过交易所止损单,将依赖 WebSocket 监控") else: logger.error(f"{symbol} ❌ 止损单挂单失败!将依赖WebSocket监控,但可能无法及时止损") logger.error(f" 止损价格: {stop_loss:.8f}") logger.error(f" 当前价格: {current_price if current_price else '无(已尝试 WS bookTicker/ticker24h、持仓、REST)'}") logger.error(f" 持仓方向: {side}") logger.error(f" ⚠️ 警告: 没有交易所级别的止损保护,如果系统崩溃或网络中断,可能无法及时止损!") logger.error(f" 💡 建议: 检查上方或下方日志中的 API 错误码(如 -4014 触发价步长不符、-4164 名义价值不足、-2021 会立即触发)或手动在币安界面设置止损") # ⚠️ 关键修复:止损单挂单失败后,立即检查当前价格是否已触发止损 # 如果已触发,立即执行市价平仓,避免亏损扩大 if current_price and stop_loss: try: current_price_val = float(current_price) stop_loss_val = float(stop_loss) entry_price_val = float(entry_price) if entry_price else None # 检查是否已触发止损 should_close = False if side == "BUY": # 做多:当前价 <= 止损价,触发止损 if current_price_val <= stop_loss_val: should_close = True elif side == "SELL": # 做空:当前价 >= 止损价,触发止损 if current_price_val >= stop_loss_val: should_close = True if should_close: entry_price_str = f"{entry_price_val:.8f}" if entry_price_val is not None else 'N/A' logger.error("=" * 80) logger.error(f"{symbol} ⚠️ 止损单挂单失败,但当前价格已触发止损,立即执行市价平仓保护!") logger.error(f" 当前价格: {current_price_val:.8f}") logger.error(f" 止损价格: {stop_loss_val:.8f}") logger.error(f" 入场价格: {entry_price_str}") logger.error(f" 持仓方向: {side}") logger.error(f" 价格偏离: {abs(current_price_val - stop_loss_val):.8f} ({abs(current_price_val - stop_loss_val)/stop_loss_val*100:.2f}%)") logger.error("=" * 80) # 立即执行市价平仓 if await self.close_position(symbol, reason='stop_loss'): logger.info(f"{symbol} ✓ 止损平仓成功(止损单挂单失败后的保护措施)") return else: # 未触发止损,但需要增强WebSocket监控 logger.warning(f"{symbol} ⚠️ 止损单挂单失败,当前价格未触发止损,将依赖WebSocket监控") logger.warning(f" 当前价格: {current_price_val:.8f}, 止损价格: {stop_loss_val:.8f}") logger.warning(f" 价格距离止损: {abs(current_price_val - stop_loss_val):.8f} ({abs(current_price_val - stop_loss_val)/stop_loss_val*100:.2f}%)") except Exception as e: logger.warning(f"{symbol} 检查止损触发条件时出错: {e},继续依赖WebSocket监控") # 在挂止盈单前,检查当前价格是否已经触发止盈 if current_price and take_profit: try: current_price_val = float(current_price) take_profit_val = float(take_profit) # 检查是否已经触发止盈 # ⚠️ 关键修复:增加0.5%的缓冲,避免因价格轻微触及或市场波动导致的过早止盈 # 如果价格只是刚好碰到止盈价,不立即触发,而是尝试挂单或让WebSocket监控决定 triggered_tp = False buffer_pct = 0.005 # 0.5% buffer if side == "BUY": # 做多:当前价 >= 止盈价 * (1 + buffer) if current_price_val >= take_profit_val * (1 + buffer_pct): triggered_tp = True elif side == "SELL": # 做空:当前价 <= 止盈价 * (1 - buffer) if current_price_val <= take_profit_val * (1 - buffer_pct): triggered_tp = True if triggered_tp: logger.info(f"{symbol} 🎯 当前价格({current_price_val:.8f})已显著超过止盈价({take_profit_val:.8f}),立即执行市价止盈!") await self.close_position(symbol, reason='take_profit') return except Exception as e: logger.debug(f"{symbol} 检查止盈触发条件时出错: {e}") try: 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", client_algo_id=client_algo_id_tp, ) except Exception as e: # 处理 -2021: Order would immediately trigger error_msg = str(e) if "-2021" in error_msg or "immediately trigger" in error_msg: # ⚠️ 关键修复:如果止盈单会立即触发,不立即平仓,而是依赖WebSocket监控 # 这样可以避免因价格微小波动导致的过早止盈,让利润奔跑(WebSocket有最小盈利检查) logger.warning(f"{symbol} ⚠️ 止盈单会立即触发(-2021),跳过挂交易所止盈单,将依赖WebSocket监控执行止盈") # await self.close_position(symbol, reason='take_profit') <-- 已移除立即平仓 return else: logger.warning(f"{symbol} 挂止盈单失败: {e}") tp_order = None if tp_order and entry_order_id: algo_id = tp_order.get("algoId") if algo_id is not None: try: rc = getattr(self.client, "redis_cache", None) if rc: await rc.set( f"ats:algo2entry:{self.account_id}:{algo_id}", str(entry_order_id), ttl=7 * 86400, ) except Exception: pass if tp_order: logger.info( f"[账号{self.account_id}] {symbol} ✓ 止盈单已成功挂到交易所: {tp_order.get('algoId', 'N/A')} " f"触发价={float(take_profit):.4f}" ) else: logger.warning(f"{symbol} ⚠️ 止盈单挂单失败,将依赖WebSocket监控") try: # Algo 接口返回 algoId position_info["exchangeSlOrderId"] = sl_order.get("algoId") if isinstance(sl_order, dict) else None except Exception: position_info["exchangeSlOrderId"] = None try: position_info["exchangeTpOrderId"] = tp_order.get("algoId") 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"[账号{self.account_id}] {symbol} 已挂币安保护单 [{sync_type}]: " f"SL={position_info.get('exchangeSlOrderId') or '-'} 触发价={float(stop_loss):.4f} | " f"TP={position_info.get('exchangeTpOrderId') or '-'} 触发价={float(take_profit):.4f}" ) if sync_type == "保本/移动止损同步": _log_trailing_stop_event( self.account_id, symbol, "sync_to_exchange_ok", stop_loss=float(stop_loss), take_profit=float(take_profit), msg="已挂币安保护单" ) logger.info(f"[账号{self.account_id}] {symbol} 止损/止盈同步至交易所完成") else: logger.warning(f"[账号{self.account_id}] {symbol} 同步结束但未挂上保护单(止损或止盈挂单均失败),将依赖 WebSocket 监控") async def check_stop_loss_take_profit(self) -> List[str]: """ 检查止损止盈 Returns: 需要平仓的交易对列表 """ closed_positions = [] try: # 获取当前持仓 positions = await self._get_open_positions() position_dict = {p['symbol']: p for p in positions} for symbol, position_info in list(self.active_positions.items()): if symbol not in position_dict: # 持仓已不存在,移除记录 del self.active_positions[symbol] continue current_position = position_dict[symbol] # 统一转为 float,避免 Decimal 与 float 运算报错(DB/补建可能返回 Decimal) entry_price = float(position_info['entryPrice']) quantity = float(position_info['quantity']) # 获取当前标记价格 current_price = current_position.get('markPrice', 0) if current_price == 0: # 如果标记价格为0,尝试从ticker获取 ticker = await self.client.get_ticker_24h(symbol) if ticker: current_price = float(ticker.get('price', 0) or 0) else: current_price = entry_price else: current_price = float(current_price) # 计算当前盈亏(基于保证金) leverage = float(position_info.get('leverage', 10) or 10) position_value = entry_price * quantity margin = position_value / leverage if leverage > 0 else position_value # 计算盈亏金额 if position_info['side'] == 'BUY': pnl_amount = (current_price - entry_price) * quantity else: pnl_amount = (entry_price - current_price) * quantity # 计算盈亏百分比(相对于保证金,与币安一致) pnl_percent_margin = (pnl_amount / margin * 100) if margin > 0 else 0 # 也计算价格百分比(用于显示和移动止损) if position_info['side'] == 'BUY': pnl_percent_price = ((current_price - entry_price) / entry_price) * 100 else: pnl_percent_price = ((entry_price - current_price) / entry_price) * 100 if bool(config.TRADING_CONFIG.get('POSITION_DETAILED_LOG_ENABLED', False)): stop_loss_snapshot = position_info.get('stopLoss') take_profit_1_snapshot = position_info.get('takeProfit1') take_profit_2_snapshot = position_info.get('takeProfit2', position_info.get('takeProfit')) logger.info( f"{symbol} [持仓监控快照] side={position_info['side']} | entry={entry_price:.6f} | " f"price={current_price:.6f} | sl={stop_loss_snapshot if stop_loss_snapshot is not None else '-'} | " f"tp1={take_profit_1_snapshot if take_profit_1_snapshot is not None else '-'} | " f"tp2={take_profit_2_snapshot if take_profit_2_snapshot is not None else '-'} | " f"roe_margin={pnl_percent_margin:.2f}% | price_change={pnl_percent_price:.2f}%" ) # 更新最大盈利(基于保证金)及最后一次创新高时间(用于滞涨早止盈) max_profit = float(position_info.get('maxProfit', 0) or 0) if pnl_percent_margin > max_profit: position_info['maxProfit'] = pnl_percent_margin position_info['lastNewHighTs'] = time.time() # 滞涨早止盈:曾涨到约 N% 后 X 小时内未创新高 → 分批减仓 + 抬止损(与早止盈/移动止损并列,不冲突) stagnation_enabled = bool(config.TRADING_CONFIG.get('STAGNATION_EARLY_EXIT_ENABLED', False)) if stagnation_enabled and not position_info.get('stagnationExitTriggered', False): min_runup = float(config.TRADING_CONFIG.get('STAGNATION_MIN_RUNUP_PCT', 10) or 10) stall_hours = float(config.TRADING_CONFIG.get('STAGNATION_NO_NEW_HIGH_HOURS', 3) or 3) partial_pct = float(config.TRADING_CONFIG.get('STAGNATION_PARTIAL_CLOSE_PCT', 0.5) or 0.5) lock_pct = float(config.TRADING_CONFIG.get('STAGNATION_LOCK_PCT', 5) or 5) last_high_ts = position_info.get('lastNewHighTs') if last_high_ts is not None and max_profit >= min_runup and pnl_percent_margin < max_profit: stall_sec = stall_hours * 3600 if (time.time() - last_high_ts) >= stall_sec: logger.info( f"{symbol} [滞涨早止盈] 曾达浮盈{max_profit:.2f}%≥{min_runup}%," f"已{stall_hours:.1f}h未创新高,执行分批减仓+抬止损" ) try: close_side = 'SELL' if position_info['side'] == 'BUY' else 'BUY' close_position_side = 'LONG' if position_info['side'] == 'BUY' else 'SHORT' remaining_qty = float(position_info.get('remainingQuantity', quantity)) partial_quantity = remaining_qty * partial_pct live_amt = await self._get_live_position_amt(symbol, position_side=close_position_side) if live_amt is not None and abs(live_amt) > 0: partial_quantity = min(partial_quantity, abs(live_amt)) partial_quantity = await self._adjust_close_quantity(symbol, partial_quantity) if partial_quantity > 0: partial_order = await self.client.place_order( symbol=symbol, side=close_side, quantity=partial_quantity, order_type='MARKET', reduce_only=True, position_side=close_position_side, ) if partial_order: position_info['partialProfitTaken'] = True position_info['remainingQuantity'] = remaining_qty - partial_quantity logger.info(f"{symbol} [滞涨早止盈] 部分平仓{partial_quantity:.4f},剩余{position_info['remainingQuantity']:.4f}") remaining_after = float(position_info.get('remainingQuantity', quantity)) lev = float(position_info.get('leverage', 10) or 10) rem_margin = (entry_price * remaining_after) / lev if lev > 0 else (entry_price * remaining_after) lock_pct_use = max(lock_pct, max_profit / 2.0) side_here = position_info['side'] new_sl = self._stop_price_to_lock_pct(entry_price, side_here, rem_margin, remaining_after, lock_pct_use) breakeven = self._breakeven_stop_price(entry_price, side_here) current_sl = position_info.get('stopLoss') set_sl = (side_here == 'BUY' and (current_sl is None or new_sl > current_sl) and new_sl >= breakeven) or ( side_here == 'SELL' and (current_sl is None or new_sl < current_sl) and new_sl <= breakeven) if set_sl: position_info['stopLoss'] = new_sl logger.info(f"{symbol} [滞涨早止盈] 剩余仓位止损上移至 {new_sl:.4f},锁定约{lock_pct_use:.1f}%利润") try: await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price) except Exception as sync_e: logger.warning(f"{symbol} [滞涨早止盈] 同步止损至交易所失败: {sync_e}") position_info['stagnationExitTriggered'] = True except Exception as e: logger.warning(f"{symbol} [滞涨早止盈] 执行异常: {e}", exc_info=False) # 移动止损逻辑(盈利后保护利润,基于保证金) # 每次检查时从Redis重新加载配置,确保配置修改能即时生效 try: if config._config_manager: config._config_manager.reload_from_redis() config.TRADING_CONFIG = config._get_trading_config() except Exception as e: logger.debug(f"从Redis重新加载配置失败: {e}") # 山寨币早止盈:盈利达标且持仓满 N 小时则市价止盈,不必等挂单 TP,避免反转回吐 early_tp_enabled = config.TRADING_CONFIG.get('EARLY_TAKE_PROFIT_ENABLED', True) if early_tp_enabled and pnl_percent_margin > 0: min_pnl_pct = float(config.TRADING_CONFIG.get('EARLY_TAKE_PROFIT_MIN_PNL_PCT', 10) or 10) min_hold_hours = float(config.TRADING_CONFIG.get('EARLY_TAKE_PROFIT_MIN_HOLD_HOURS', 2) or 2) hold_hours = 0.0 entry_time = position_info.get('entryTime') if entry_time is not None: try: if isinstance(entry_time, datetime): hold_hours = (get_beijing_time() - entry_time).total_seconds() / 3600.0 elif isinstance(entry_time, str) and len(entry_time) >= 19: entry_dt = datetime.strptime(entry_time[:19], '%Y-%m-%d %H:%M:%S') hold_hours = (get_beijing_time() - entry_dt).total_seconds() / 3600.0 else: hold_hours = (time.time() - (float(entry_time) if isinstance(entry_time, (int, float)) else 0)) / 3600.0 except Exception: pass if pnl_percent_margin >= min_pnl_pct and hold_hours >= min_hold_hours: logger.info( f"{symbol} [早止盈] 盈利{pnl_percent_margin:.2f}%≥{min_pnl_pct}% 且持仓{hold_hours:.1f}h≥{min_hold_hours}h,市价止盈离场(山寨币不必久拿)" ) if await self.close_position(symbol, reason='early_take_profit'): closed_positions.append(symbol) continue # 检查是否启用移动止损(默认False,需要显式启用) profit_protection_enabled = bool(config.TRADING_CONFIG.get('PROFIT_PROTECTION_ENABLED', True)) use_trailing = profit_protection_enabled and bool(config.TRADING_CONFIG.get('USE_TRAILING_STOP', True)) if use_trailing: logger.debug(f"{symbol} [移动止损] 已启用,将检查移动止损逻辑") else: if not profit_protection_enabled: logger.debug(f"{symbol} [移动止损/保本] 已禁用(PROFIT_PROTECTION_ENABLED=False)") else: logger.debug(f"{symbol} [移动止损] 已禁用(USE_TRAILING_STOP=False),跳过移动止损检查") if use_trailing: trailing_activation = config.TRADING_CONFIG.get('TRAILING_STOP_ACTIVATION', 0.10) # 相对于保证金,默认 10% trailing_protect = config.TRADING_CONFIG.get('TRAILING_STOP_PROTECT', 0.01) # 相对于保证金 # ⚠️ 关键修复:配置值格式转换(兼容百分比形式和比例形式) # 如果值>1,认为是百分比形式,转换为比例形式 if trailing_activation > 1: trailing_activation = trailing_activation / 100.0 if trailing_protect > 1: trailing_protect = trailing_protect / 100.0 lock_pct = config.TRADING_CONFIG.get('LOCK_PROFIT_AT_BREAKEVEN_AFTER_PCT') if lock_pct is None: lock_pct = 0.03 if lock_pct and lock_pct > 1: lock_pct = lock_pct / 100.0 if not position_info.get('trailingStopActivated', False): # 盈利达一定比例时尽早将止损移至含手续费保本(与实时监控一致) if lock_pct > 0 and not position_info.get('breakevenStopSet', False) and pnl_percent_margin >= lock_pct * 100: breakeven = self._breakeven_stop_price(entry_price, side) current_sl = position_info.get('stopLoss') set_be = (side == 'BUY' and (current_sl is None or current_sl < breakeven)) or (side == 'SELL' and (current_sl is None or current_sl > breakeven)) if set_be: position_info['stopLoss'] = breakeven position_info['breakevenStopSet'] = True logger.info(f"{symbol} [定时检查] 盈利{pnl_percent_margin:.2f}%≥{lock_pct*100:.0f}%,止损已移至含手续费保本价 {breakeven:.4f}") _log_trailing_stop_event(self.account_id, symbol, "breakeven_set", breakeven=breakeven, pnl_pct=pnl_percent_margin, source="定时检查") logger.info(f"{symbol} [定时检查] 尝试将保本止损同步至交易所") try: await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price) _log_trailing_stop_event(self.account_id, symbol, "breakeven_sync_ok", breakeven=breakeven, source="定时检查") except Exception as sync_e: _log_trailing_stop_event(self.account_id, symbol, "breakeven_sync_fail", breakeven=breakeven, error=str(sync_e), source="定时检查") logger.warning( f"{symbol} [定时检查] 同步保本止损至交易所失败: {type(sync_e).__name__}: {sync_e}", exc_info=False, ) # 盈利超过阈值后(相对于保证金),激活移动止损 if pnl_percent_margin > trailing_activation * 100: position_info['trailingStopActivated'] = True breakeven = self._breakeven_stop_price(entry_price, side) position_info['stopLoss'] = breakeven logger.info( f"{symbol} 移动止损激活: 止损移至含手续费保本价 {breakeven:.4f} (入场: {entry_price:.4f}) " f"(盈利: {pnl_percent_margin:.2f}% of margin)" ) _log_trailing_stop_event(self.account_id, symbol, "trailing_activated", breakeven=breakeven, pnl_pct=pnl_percent_margin, source="定时检查") logger.info(f"{symbol} [定时检查] 尝试将移动止损同步至交易所") try: await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price) _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=breakeven, source="定时检查") logger.info(f"{symbol} [定时检查] 已同步移动止损至交易所") except Exception as sync_e: _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=breakeven, error=str(sync_e), source="定时检查") logger.warning( f"{symbol} [定时检查] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}", exc_info=False, ) else: # 盈利超过阈值后,止损移至保护利润位(基于保证金) # 如果已经部分止盈,使用剩余仓位计算 if position_info.get('partialProfitTaken', False): remaining_quantity = position_info.get('remainingQuantity', quantity) remaining_margin = (entry_price * remaining_quantity) / leverage if leverage > 0 else (entry_price * remaining_quantity) protect_amount = max(remaining_margin * trailing_protect, self._min_protect_amount_for_fees(remaining_margin, leverage)) if position_info['side'] == 'BUY': remaining_pnl = (current_price - entry_price) * remaining_quantity else: remaining_pnl = (entry_price - current_price) * remaining_quantity if position_info['side'] == 'BUY': new_stop_loss = entry_price + (remaining_pnl - protect_amount) / remaining_quantity new_stop_loss = max(new_stop_loss, self._breakeven_stop_price(entry_price, 'BUY')) current_sl = float(position_info['stopLoss']) if position_info.get('stopLoss') is not None else None if current_sl is None or new_stop_loss > current_sl: position_info['stopLoss'] = new_stop_loss logger.info( f"{symbol} 移动止损更新(剩余仓位): {new_stop_loss:.4f} " f"(保护{trailing_protect*100:.1f}% of remaining margin = {protect_amount:.4f} USDT, " f"剩余数量: {remaining_quantity:.4f})" ) _log_trailing_stop_event(self.account_id, symbol, "trailing_update", new_stop_loss=new_stop_loss, source="定时检查-剩余仓位") try: await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price) _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=new_stop_loss, source="定时检查") logger.info(f"{symbol} [定时检查] 已同步移动止损至交易所") except Exception as sync_e: _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=new_stop_loss, error=str(sync_e), source="定时检查") logger.warning( f"{symbol} [定时检查] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}", exc_info=False, ) else: # 做空锁利:止损下移,锁住 protect_amount 利润。(entry - stop)*q = protect → stop = entry - protect/q new_stop_loss = entry_price - protect_amount / remaining_quantity new_stop_loss = max(new_stop_loss, self._breakeven_stop_price(entry_price, 'SELL')) current_sl = float(position_info['stopLoss']) if position_info.get('stopLoss') is not None else None # 仅当新止损低于当前止损(下移锁利)且高于市价(锁住的是利润)时更新 if (current_sl is None or new_stop_loss < current_sl) and new_stop_loss > current_price and remaining_pnl > 0: position_info['stopLoss'] = new_stop_loss logger.info( f"{symbol} 移动止损更新(剩余仓位-做空锁利): {new_stop_loss:.4f} " f"(保护{trailing_protect*100:.1f}% of remaining margin = {protect_amount:.4f} USDT, " f"剩余数量: {remaining_quantity:.4f})" ) _log_trailing_stop_event(self.account_id, symbol, "trailing_update", new_stop_loss=new_stop_loss, source="定时检查-剩余仓位") try: await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price) _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=new_stop_loss, source="定时检查") logger.info(f"{symbol} [定时检查] 已同步移动止损至交易所") except Exception as sync_e: _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=new_stop_loss, error=str(sync_e), source="定时检查") logger.warning( f"{symbol} [定时检查] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}", exc_info=False, ) else: # 未部分止盈,使用原始仓位计算;保护金额至少覆盖手续费 protect_amount = max(margin * trailing_protect, self._min_protect_amount_for_fees(margin, leverage)) if position_info['side'] == 'BUY': new_stop_loss = entry_price + (pnl_amount - protect_amount) / quantity new_stop_loss = max(new_stop_loss, self._breakeven_stop_price(entry_price, 'BUY')) current_sl = float(position_info['stopLoss']) if position_info.get('stopLoss') is not None else None if current_sl is None or new_stop_loss > current_sl: position_info['stopLoss'] = new_stop_loss logger.info( f"{symbol} 移动止损更新: {new_stop_loss:.4f} " f"(保护{trailing_protect*100:.1f}% of margin = {protect_amount:.4f} USDT)" ) _log_trailing_stop_event(self.account_id, symbol, "trailing_update", new_stop_loss=new_stop_loss, source="定时检查") try: await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price) _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=new_stop_loss, source="定时检查") logger.info(f"{symbol} [定时检查] 已同步移动止损至交易所") except Exception as sync_e: _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=new_stop_loss, error=str(sync_e), source="定时检查") logger.warning( f"{symbol} [定时检查] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}", exc_info=False, ) else: # 做空锁利:止损下移(从初始高位往市价方向移),锁住 protect_amount。(entry - stop)*q = protect → stop = entry - protect/q new_stop_loss = entry_price - protect_amount / quantity new_stop_loss = max(new_stop_loss, self._breakeven_stop_price(entry_price, 'SELL')) current_sl = float(position_info['stopLoss']) if position_info.get('stopLoss') is not None else None # 仅当新止损低于当前止损(下移锁利)且高于市价(锁住的是利润)时更新 if (current_sl is None or new_stop_loss < current_sl) and new_stop_loss > current_price and pnl_amount > 0: position_info['stopLoss'] = new_stop_loss logger.info( f"{symbol} 移动止损更新(做空锁利): {new_stop_loss:.4f} " f"(保护{trailing_protect*100:.1f}% of margin = {protect_amount:.4f} USDT)" ) _log_trailing_stop_event(self.account_id, symbol, "trailing_update", new_stop_loss=new_stop_loss, source="定时检查") try: await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price) _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=new_stop_loss, source="定时检查") logger.info(f"{symbol} [定时检查] 已同步移动止损至交易所") except Exception as sync_e: _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=new_stop_loss, error=str(sync_e), source="定时检查") logger.warning( f"{symbol} [定时检查] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}", exc_info=False, ) # 检查止损(使用更新后的止损价,基于保证金收益比) # ⚠️ 重要:止损检查应该在时间锁之前,止损必须立即执行 stop_loss_raw = position_info.get('stopLoss') stop_loss = float(stop_loss_raw) if stop_loss_raw is not None else None should_close_due_to_sl = False exit_reason_sl = None if stop_loss is None: logger.warning(f"{symbol} 止损价未设置,跳过止损检查") elif stop_loss is not None: # 计算止损对应的保证金百分比目标 if position_info['side'] == 'BUY': stop_loss_amount = (entry_price - stop_loss) * quantity else: # SELL stop_loss_amount = (stop_loss - entry_price) * quantity stop_loss_pct_margin = (stop_loss_amount / margin * 100) if margin > 0 else 0 # 直接比较当前盈亏百分比与止损目标(基于保证金) if pnl_percent_margin <= -stop_loss_pct_margin: should_close_due_to_sl = True # ⚠️ 2026-01-27优化:如果已部分止盈,细分状态 partial_profit_taken = position_info.get('partialProfitTaken', False) if partial_profit_taken: if position_info.get('trailingStopActivated'): exit_reason_sl = 'take_profit_partial_then_trailing_stop' else: exit_reason_sl = 'take_profit_partial_then_stop' else: exit_reason_sl = 'trailing_stop' if position_info.get('trailingStopActivated') else 'stop_loss' # 计算持仓时间 entry_time = position_info.get('entryTime') hold_time_minutes = 0 if entry_time: try: if isinstance(entry_time, datetime): hold_time_sec = int((get_beijing_time() - entry_time).total_seconds()) else: hold_time_sec = int(time.time() - (float(entry_time) if isinstance(entry_time, (int, float)) else 0)) hold_time_minutes = hold_time_sec / 60.0 except Exception: hold_time_minutes = 0 # 详细诊断日志:记录平仓时的所有关键信息 logger.warning("=" * 80) logger.warning(f"{symbol} [平仓诊断日志] ===== 触发止损平仓 =====") logger.warning(f" 平仓原因: {exit_reason_sl}") logger.warning(f" 入场价格: {entry_price:.6f} USDT") logger.warning(f" 当前价格: {current_price:.4f} USDT") logger.warning(f" 止损价格: {stop_loss:.4f} USDT") logger.warning(f" 持仓数量: {quantity:.4f}") logger.warning(f" 持仓时间: {hold_time_minutes:.1f} 分钟") logger.warning(f" 入场时间: {entry_time}") logger.warning(f" 当前盈亏: {pnl_percent_margin:.2f}% of margin") logger.warning(f" 止损目标: -{stop_loss_pct_margin*100:.2f}% of margin") logger.warning(f" 亏损金额: {abs(pnl_amount):.4f} USDT") if position_info.get('trailingStopActivated'): logger.warning(f" 移动止损: 已激活(从初始止损 {position_info.get('initialStopLoss', 'N/A')} 调整)") logger.warning("=" * 80) # 止损必须立即执行,不受时间锁限制 # 更新数据库 # 更新数据库 if DB_AVAILABLE: trade_id = position_info.get('tradeId') if trade_id: try: # 计算持仓持续时间 entry_time = position_info.get('entryTime') duration_minutes = None if entry_time: try: if isinstance(entry_time, str): entry_dt = datetime.strptime(entry_time, '%Y-%m-%d %H:%M:%S') else: entry_dt = entry_time exit_dt = get_beijing_time() # 使用北京时间计算持续时间 duration = exit_dt - entry_dt duration_minutes = int(duration.total_seconds() / 60) except Exception as e: logger.debug(f"计算持仓持续时间失败: {e}") # 获取策略类型 strategy_type = position_info.get('strategyType', 'trend_following') Trade.update_exit( trade_id=trade_id, exit_price=current_price, exit_reason=exit_reason_sl, pnl=pnl_amount, pnl_percent=pnl_percent_margin, strategy_type=strategy_type, duration_minutes=duration_minutes ) except Exception as e: logger.warning(f"更新止损记录失败: {e}") # ⚠️ 关键修复:止损必须立即执行,不受时间锁限制 if await self.close_position(symbol, reason=exit_reason_sl): closed_positions.append(symbol) continue # 止损已执行,跳过后续止盈检查 # 检查分步止盈(基于保证金收益比) # ⚠️ 优化:已移除止盈时间锁,止盈可以立即执行(与止损一致) # 理由:1) 止损已不受时间锁限制,止盈也应该一致 # 2) 分步止盈策略本身已提供利润保护(50%在1:1止盈,剩余保本) # 3) 交易所级别止盈单已提供保护 # 4) 及时止盈可以保护利润,避免价格回落 take_profit_1_raw = position_info.get('takeProfit1') # 第一目标(盈亏比1:1) take_profit_1 = float(take_profit_1_raw) if take_profit_1_raw is not None else None take_profit_2_raw = position_info.get('takeProfit2', position_info.get('takeProfit')) # 第二目标 take_profit_2 = float(take_profit_2_raw) if take_profit_2_raw is not None else None partial_profit_taken = position_info.get('partialProfitTaken', False) remaining_quantity = float(position_info.get('remainingQuantity', quantity)) # 第一目标:TAKE_PROFIT_1_PERCENT 止盈(默认15%保证金),了结50%仓位 # ✅ 已移除时间锁限制,可以立即执行 if not partial_profit_taken and take_profit_1 is not None: # 直接使用配置的 TAKE_PROFIT_1_PERCENT,与开仓时计算的第一目标一致 take_profit_1_pct_margin_config = config.TRADING_CONFIG.get('TAKE_PROFIT_1_PERCENT', 0.20) # 兼容百分比形式和比例形式 if take_profit_1_pct_margin_config > 1: take_profit_1_pct_margin_config = take_profit_1_pct_margin_config / 100.0 take_profit_1_pct_margin = take_profit_1_pct_margin_config * 100 # 转换为百分比 # 直接比较当前盈亏百分比与第一目标(基于保证金,使用配置值) if pnl_percent_margin >= take_profit_1_pct_margin: logger.info( f"{symbol} 触发第一目标止盈(盈亏比1:1,基于保证金): " f"当前盈亏={pnl_percent_margin:.2f}% of margin >= 目标={take_profit_1_pct_margin:.2f}% of margin | " f"当前价={current_price:.4f}, 目标价={take_profit_1:.4f}" ) # 部分平仓50% partial_quantity = quantity * 0.5 try: # 部分平仓 close_side = 'SELL' if position_info['side'] == 'BUY' else 'BUY' close_position_side = 'LONG' if position_info['side'] == 'BUY' else 'SHORT' # 二次校验并截断数量,避免 reduceOnly 被拒绝(-2022) live_amt = await self._get_live_position_amt(symbol, position_side=close_position_side) if live_amt is None or abs(live_amt) <= 0: logger.warning(f"{symbol} 部分止盈:实时持仓已为0,跳过部分平仓") continue partial_quantity = min(partial_quantity, abs(live_amt)) partial_quantity = await self._adjust_close_quantity(symbol, partial_quantity) if partial_quantity <= 0: logger.warning(f"{symbol} 部分止盈:数量调整后为0,跳过") continue partial_order = await self.client.place_order( symbol=symbol, side=close_side, quantity=partial_quantity, order_type='MARKET', reduce_only=True, # 部分止盈必须 reduceOnly,避免反向开仓 position_side=close_position_side, # 兼容对冲模式:指定要减少的持仓方向 ) if partial_order: position_info['partialProfitTaken'] = True position_info['remainingQuantity'] = remaining_quantity - partial_quantity logger.info( f"{symbol} 部分止盈成功: 平仓{partial_quantity:.4f},剩余{position_info['remainingQuantity']:.4f}" ) # 分步止盈后的“保本”处理:仅在盈利保护总开关开启时移至含手续费保本价 if profit_protection_enabled: breakeven = self._breakeven_stop_price(entry_price, side) position_info['stopLoss'] = breakeven logger.info( f"{symbol} 部分止盈后:剩余仓位止损移至含手续费保本价 {breakeven:.4f}(入场: {entry_price:.4f})," f"剩余50%仓位追求1.5:1止盈目标" ) else: # 兜底:可能遇到 -2022(reduceOnly rejected)等竞态,重新查一次持仓 try: live2 = await self._get_live_position_amt(symbol, position_side=close_position_side) except Exception: live2 = None if live2 is None or abs(live2) <= 0: logger.warning(f"{symbol} 部分止盈下单返回None,但实时持仓已为0,跳过") continue logger.warning(f"{symbol} 部分止盈下单返回None(实时持仓仍存在: {live2}),稍后将继续由止损/止盈逻辑处理") except Exception as e: logger.error(f"{symbol} 部分止盈失败: {e}") # 第二目标:原始止盈价,平掉剩余仓位(基于保证金收益比) # ✅ 已移除时间锁限制,可以立即执行 if partial_profit_taken and take_profit_2 is not None: # 计算第二目标对应的保证金百分比 if position_info['side'] == 'BUY': take_profit_2_amount = (take_profit_2 - entry_price) * remaining_quantity else: # SELL take_profit_2_amount = (entry_price - take_profit_2) * remaining_quantity # 使用剩余仓位的保证金 remaining_margin = (entry_price * remaining_quantity) / leverage if leverage > 0 else (entry_price * remaining_quantity) take_profit_2_pct_margin = (take_profit_2_amount / remaining_margin * 100) if remaining_margin > 0 else 0 # 计算剩余仓位的当前盈亏 if position_info['side'] == 'BUY': remaining_pnl_amount = (current_price - entry_price) * remaining_quantity else: remaining_pnl_amount = (entry_price - current_price) * remaining_quantity remaining_pnl_pct_margin = (remaining_pnl_amount / remaining_margin * 100) if remaining_margin > 0 else 0 # 直接比较剩余仓位盈亏百分比与第二目标(基于保证金) if remaining_pnl_pct_margin >= take_profit_2_pct_margin: logger.info( f"{symbol} 触发第二目标止盈(基于保证金): " f"剩余仓位盈亏={remaining_pnl_pct_margin:.2f}% of margin >= 目标={take_profit_2_pct_margin:.2f}% of margin | " f"当前价={current_price:.4f}, 目标价={take_profit_2:.4f}, " f"剩余数量={remaining_quantity:.4f}" ) # ⚠️ 关键修复:统一使用 'take_profit_partial_then_take_profit' 来区分"第一目标止盈后第二目标止盈" exit_reason = 'take_profit_partial_then_take_profit' # 计算总盈亏(原始仓位 + 部分止盈的盈亏) # 部分止盈时的价格需要从数据库或记录中获取,这里简化处理 total_pnl_amount = remaining_pnl_amount # 简化:只计算剩余仓位盈亏 total_pnl_percent = (total_pnl_amount / margin * 100) if margin > 0 else 0 # 更新数据库 if DB_AVAILABLE: trade_id = position_info.get('tradeId') if trade_id: try: # 计算持仓持续时间 entry_time = position_info.get('entryTime') duration_minutes = None if entry_time: try: if isinstance(entry_time, str): entry_dt = datetime.strptime(entry_time, '%Y-%m-%d %H:%M:%S') else: entry_dt = entry_time exit_dt = get_beijing_time() # 使用北京时间计算持续时间 duration = exit_dt - entry_dt duration_minutes = int(duration.total_seconds() / 60) except Exception as e: logger.debug(f"计算持仓持续时间失败: {e}") # 获取策略类型 strategy_type = position_info.get('strategyType', 'trend_following') Trade.update_exit( trade_id=trade_id, exit_price=current_price, exit_reason=exit_reason, pnl=total_pnl_amount, pnl_percent=total_pnl_percent, strategy_type=strategy_type, duration_minutes=duration_minutes ) except Exception as e: logger.warning(f"更新止盈记录失败: {e}") if await self.close_position(symbol, reason=exit_reason): closed_positions.append(symbol) continue else: # 如果未部分止盈,但达到【第二目标】止盈价对应的收益比时,才全部平仓 # ⚠️ 修复:不再使用 TAKE_PROFIT_PERCENT(10%) 作为全平条件,否则会“刚赚一点就整仓止盈” # 改为使用 take_profit_2 价格对应的保证金收益%,与第一目标(20%) 取较大者,避免盈利过少 take_profit_2_full = position_info.get('takeProfit2', position_info.get('takeProfit')) take_profit_2_full = float(take_profit_2_full) if take_profit_2_full is not None else None if take_profit_2_full is not None and margin and margin > 0: if position_info['side'] == 'BUY': take_profit_2_amount = (take_profit_2_full - entry_price) * quantity else: take_profit_2_amount = (entry_price - take_profit_2_full) * quantity take_profit_2_pct_margin = (take_profit_2_amount / margin * 100) if margin > 0 else 0 # 至少要求达到第一目标对应的收益%(如 20%),避免过早全平 take_profit_1_pct = (config.TRADING_CONFIG.get('TAKE_PROFIT_1_PERCENT', 0.20) or 0.20) if take_profit_1_pct > 1: take_profit_1_pct = take_profit_1_pct / 100.0 min_pct_for_full_tp = max(take_profit_2_pct_margin, take_profit_1_pct * 100) if pnl_percent_margin >= min_pct_for_full_tp: logger.info( f"{symbol} 触发止盈(第二目标/保证金): " f"当前盈亏={pnl_percent_margin:.2f}% of margin >= 目标={min_pct_for_full_tp:.2f}% | " f"当前价={current_price:.4f}, 第二目标价={take_profit_2_full:.4f}" ) exit_reason = 'take_profit' # 更新数据库 if DB_AVAILABLE: trade_id = position_info.get('tradeId') if trade_id: try: # 计算持仓持续时间和策略类型 entry_time = position_info.get('entryTime') duration_minutes = None if entry_time: try: from datetime import datetime if isinstance(entry_time, str): entry_dt = datetime.strptime(entry_time, '%Y-%m-%d %H:%M:%S') else: entry_dt = entry_time exit_dt = get_beijing_time() # 使用北京时间计算持续时间 duration = exit_dt - entry_dt duration_minutes = int(duration.total_seconds() / 60) except Exception as e: logger.debug(f"计算持仓持续时间失败: {e}") strategy_type = position_info.get('strategyType', 'trend_following') Trade.update_exit( trade_id=trade_id, exit_price=current_price, exit_reason=exit_reason, pnl=pnl_amount, pnl_percent=pnl_percent_margin, strategy_type=strategy_type, duration_minutes=duration_minutes ) except Exception as e: logger.warning(f"更新止盈记录失败: {e}") if await self.close_position(symbol, reason=exit_reason): closed_positions.append(symbol) continue except Exception as e: err_msg = str(e).strip() or repr(e) or type(e).__name__ logger.error(f"检查止损止盈失败: {err_msg}") return closed_positions async def get_position_summary(self) -> Dict: """ 获取持仓摘要 Returns: 持仓摘要信息 """ try: positions = await self._get_open_positions() balance = await self._get_account_balance() total_pnl = sum(p['unRealizedProfit'] for p in positions) position_list = [] for p in positions: symbol = p['symbol'] pos_data = { 'symbol': symbol, 'positionAmt': p['positionAmt'], 'entryPrice': p['entryPrice'], 'pnl': p['unRealizedProfit'], 'leverage': p.get('leverage', 10) } # 尝试从内存或数据库补充详细信息 active_pos = self.active_positions.get(symbol) if active_pos: pos_data.update({ 'entry_time': active_pos.get('entryTime'), 'stop_loss_price': active_pos.get('stopLoss'), 'take_profit_price': active_pos.get('takeProfit'), 'atr': active_pos.get('atr'), 'entry_reason': active_pos.get('entryReason') }) elif DB_AVAILABLE and Trade: # 从数据库查询 try: trades = Trade.get_by_symbol(symbol, status='open', account_id=self.account_id) if trades: # 取最新的一个 trade = trades[0] pos_data.update({ 'entry_time': trade.get('entry_time'), 'stop_loss_price': trade.get('stop_loss_price'), 'take_profit_price': trade.get('take_profit_price'), 'atr': trade.get('atr'), 'entry_reason': trade.get('entry_reason') }) except Exception: pass position_list.append(pos_data) return { 'totalPositions': len(positions), 'totalBalance': balance.get('total', 0), 'availableBalance': balance.get('available', 0), 'totalPnL': total_pnl, 'positions': position_list } except Exception as e: logger.error(f"获取持仓摘要失败: {e}") return {} async def _reconcile_pending_with_binance(self) -> int: """正向流程加固:对 status=pending 记录查币安订单,若已 FILLED 则更新为 open。""" try: from .pending_reconcile import reconcile_pending_with_binance return await reconcile_pending_with_binance(self.client, self.account_id) except ImportError: return 0 async def sync_positions_with_binance(self): """ 同步币安实际持仓状态与数据库状态 检查哪些持仓在数据库中还是open状态,但在币安已经不存在了 """ if not DB_AVAILABLE or not Trade: logger.debug("数据库不可用,跳过持仓状态同步") return try: logger.info("开始同步币安持仓状态与数据库...") # 0. 正向流程加固:pending 对账(补齐因 WS 断线/进程重启漏掉的 pending→open) n = await self._reconcile_pending_with_binance() if n > 0: logger.info(f"[账号{self.account_id}] pending 对账完成,{n} 条已更新为 open") # 1. 获取币安实际持仓 binance_positions = await self._get_open_positions() binance_symbols = {p['symbol'] for p in binance_positions} logger.debug(f"币安实际持仓: {len(binance_symbols)} 个 ({', '.join(binance_symbols) if binance_symbols else '无'})") # 2. 获取数据库中状态为open的交易记录(仅当前账号,限制条数防内存暴增) db_open_trades = Trade.get_all(status='open', account_id=self.account_id, limit=500) db_open_symbols = {t['symbol'] for t in db_open_trades} logger.debug(f"数据库open状态: {len(db_open_symbols)} 个 ({', '.join(db_open_symbols) if db_open_symbols else '无'})") # 2.5 用「DB 有 open 且币安也有」的持仓填充 active_positions,确保这些持仓会被后续启动的监控覆盖 in_both = db_open_symbols & binance_symbols for symbol in in_both: if symbol in self.active_positions: continue trade = next((t for t in db_open_trades if t.get('symbol') == symbol), None) pos = next((p for p in binance_positions if p.get('symbol') == symbol), None) if not trade or not pos or float(pos.get('positionAmt', 0)) == 0: continue try: entry_price = float(trade.get('entry_price', 0) or pos.get('entryPrice', 0)) quantity = abs(float(pos.get('positionAmt', 0))) side = 'BUY' if float(pos.get('positionAmt', 0)) > 0 else 'SELL' leverage = float(pos.get('leverage', 10) or trade.get('leverage', 10) or 10) stop_loss_price = trade.get('stop_loss_price') take_profit_price = trade.get('take_profit_price') or trade.get('take_profit_1') take_profit_1 = trade.get('take_profit_1') take_profit_2 = trade.get('take_profit_2') if stop_loss_price is None or take_profit_price is None: stop_loss_pct = config.TRADING_CONFIG.get('STOP_LOSS_PERCENT', 0.08) if stop_loss_pct is not None and stop_loss_pct > 1: stop_loss_pct = stop_loss_pct / 100.0 take_profit_pct = config.TRADING_CONFIG.get('TAKE_PROFIT_PERCENT', 0.15) if take_profit_pct is not None and take_profit_pct > 1: take_profit_pct = take_profit_pct / 100.0 if not take_profit_pct: take_profit_pct = (stop_loss_pct or 0.08) * 2.0 stop_loss_price = self.risk_manager.get_stop_loss_price(entry_price, side, quantity, leverage, stop_loss_pct=stop_loss_pct) take_profit_price = take_profit_price or self.risk_manager.get_take_profit_price(entry_price, side, quantity, leverage, take_profit_pct=take_profit_pct) if take_profit_2 is None and take_profit_price is not None: take_profit_2 = take_profit_price if take_profit_1 is not None and take_profit_2 is not None: if side == 'BUY': if take_profit_1 >= take_profit_2 and take_profit_2 > entry_price: closer = min(take_profit_1, take_profit_2) further = max(take_profit_1, take_profit_2) if closer > entry_price: take_profit_1 = closer take_profit_2 = further else: if take_profit_1 <= take_profit_2 and take_profit_2 < entry_price: closer = max(take_profit_1, take_profit_2) further = min(take_profit_1, take_profit_2) if closer < entry_price: take_profit_1 = closer take_profit_2 = further position_info = { 'symbol': symbol, 'side': side, 'quantity': quantity, 'entryPrice': entry_price, 'changePercent': 0, 'orderId': trade.get('entry_order_id'), 'tradeId': trade.get('id'), 'stopLoss': stop_loss_price, 'takeProfit': take_profit_price, 'takeProfit1': take_profit_1, 'takeProfit2': take_profit_2, 'partialProfitTaken': False, 'remainingQuantity': quantity, 'initialStopLoss': stop_loss_price, 'leverage': leverage, 'entryReason': trade.get('entry_reason') or 'db_sync', 'entryTime': trade.get('entry_time'), 'atr': trade.get('atr'), 'maxProfit': 0.0, 'trailingStopActivated': False, 'breakevenStopSet': False, } self.active_positions[symbol] = position_info logger.debug(f"{symbol} 已从 DB 载入到 active_positions,便于监控") except Exception as e: logger.debug(f"{symbol} 载入 active_positions 失败: {e}") # 2.6 对「有仓但交易所无 SL/TP」的持仓自动补挂止损止盈(含仅在本系统 active 的仓) for symbol in list(self.active_positions.keys()): if symbol not in binance_symbols: continue position_info = self.active_positions[symbol] if not position_info.get('stopLoss') and not position_info.get('takeProfit'): continue try: sl_from_ex, tp_from_ex = await self._get_sltp_from_exchange(symbol, position_info['side']) if sl_from_ex is not None and tp_from_ex is not None: continue ticker = await self.client.get_ticker_24h(symbol) current_price = float(ticker.get('price', 0) or position_info.get('entryPrice', 0)) await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price) logger.info(f"[账号{self.account_id}] {symbol} [同步] 检测到交易所无止损/止盈,已补挂 SL/TP") except Exception as e: logger.warning(f"[账号{self.account_id}] {symbol} [同步] 补挂 SL/TP 失败: {e}", exc_info=False) # 3. 找出在数据库中open但在币安已不存在的持仓 missing_in_binance = db_open_symbols - binance_symbols if missing_in_binance: logger.warning( f"发现 {len(missing_in_binance)} 个持仓在数据库中为open状态,但在币安已不存在: " f"{', '.join(missing_in_binance)}" ) # 4. 更新这些持仓的状态 for symbol in missing_in_binance: try: trades = Trade.get_by_symbol(symbol, status='open', account_id=self.account_id) if not trades: logger.warning(f"{symbol} [状态同步] ⚠️ 数据库中没有找到open状态的交易记录,跳过") continue # 过滤掉刚刚开仓的交易(给予60秒的同步缓冲期) # 避免因API延迟导致刚开的仓位被误判为"丢失"从而被错误关闭 import time now_ts = int(time.time()) recent_trades = [] valid_trades = [] for t in trades: try: entry_time = t.get('entry_time') if entry_time: # 处理 entry_time 可能是 datetime 对象或时间戳的情况 entry_ts = 0 if hasattr(entry_time, 'timestamp'): entry_ts = int(entry_time.timestamp()) elif isinstance(entry_time, (int, float)): entry_ts = int(entry_time) # 如果是刚刚开仓(60秒内),跳过同步关闭 if now_ts - entry_ts < 60: recent_trades.append(t) continue except Exception as e: logger.debug(f"{symbol} [状态同步] 检查开仓时间出错: {e}") valid_trades.append(t) if recent_trades: logger.info(f"{symbol} [状态同步] 发现 {len(recent_trades)} 个刚刚开仓的交易(<60s),跳过同步关闭(防止误杀)") if not valid_trades: if not recent_trades: logger.warning(f"{symbol} [状态同步] 没有符合条件的交易记录需要更新") continue logger.info(f"{symbol} [状态同步] 找到 {len(valid_trades)} 条open状态的交易记录,开始更新...") except Exception as get_trades_error: logger.error( f"{symbol} [状态同步] ❌ 获取交易记录失败: " f"错误类型={type(get_trades_error).__name__}, 错误消息={str(get_trades_error)}" ) import traceback logger.debug(f"{symbol} [状态同步] 错误详情:\n{traceback.format_exc()}") continue for trade in valid_trades: trade_id = trade.get('id') if not trade_id: logger.warning(f"{symbol} [状态同步] ⚠️ 交易记录缺少ID字段,跳过: {trade}") continue try: logger.info( f"{symbol} [状态同步] 更新交易记录状态 (ID: {trade_id})... | " f"入场价: {trade.get('entry_price', 'N/A')}, " f"数量: {trade.get('quantity', 'N/A')}, " f"方向: {trade.get('side', 'N/A')}" ) # 尝试从币安历史订单获取实际平仓价格 exit_price = None realized_pnl = None commission = None close_orders = [] try: # 优先尝试从成交记录(Trade History)获取精确的 Realized PnL # 这是最准确的方式,因为它包含了资金费率、手续费扣除后的实际盈亏 try: recent_trades = await self.client.get_recent_trades(symbol, limit=50) # 确定过滤时间戳 entry_ts_ms_filter = 0 et_obj = trade.get('entry_time') if hasattr(et_obj, 'timestamp'): entry_ts_ms_filter = int(et_obj.timestamp() * 1000) elif isinstance(et_obj, (int, float)): # 区分秒和毫秒 entry_ts_ms_filter = int(et_obj * 1000) if et_obj < 3000000000 else int(et_obj) elif isinstance(et_obj, str): try: from datetime import datetime dt_obj = datetime.strptime(et_obj, "%Y-%m-%d %H:%M:%S") entry_ts_ms_filter = int(dt_obj.timestamp() * 1000) except: pass if entry_ts_ms_filter > 0: # 筛选出入场之后的成交记录 closing_trades = [ t for t in recent_trades if t.get('time', 0) > entry_ts_ms_filter and float(t.get('realizedPnl', 0)) != 0 ] if closing_trades: total_pnl = 0.0 total_comm = 0.0 total_qty = 0.0 total_val = 0.0 for t in closing_trades: total_pnl += float(t.get('realizedPnl', 0)) total_comm += float(t.get('commission', 0)) qty = float(t.get('qty', 0)) price = float(t.get('price', 0)) total_qty += qty total_val += qty * price realized_pnl = total_pnl commission = total_comm if total_qty > 0: exit_price = total_val / total_qty logger.info( f"{symbol} [状态同步] ✓ 从成交记录获取精确盈亏: " f"PnL={realized_pnl} USDT, 均价={exit_price:.4f}" ) except Exception as trade_hist_err: err_msg = str(trade_hist_err).strip() or type(trade_hist_err).__name__ logger.warning(f"{symbol} [状态同步] 获取成交记录失败: {err_msg}") # 获取最近的平仓订单(reduceOnly=True的订单) import time end_time = int(time.time() * 1000) # 当前时间(毫秒) start_time = end_time - (7 * 24 * 60 * 60 * 1000) # 最近7天 # 获取入场时间(毫秒)用于过滤 entry_ts_ms = 0 try: et = trade.get('entry_time') if et: if isinstance(et, (int, float)): # 如果小于 3000000000,认为是秒,转毫秒 entry_ts_ms = int(et * 1000) if et < 3000000000 else int(et) elif hasattr(et, 'timestamp'): # datetime object entry_ts_ms = int(et.timestamp() * 1000) elif isinstance(et, str): # 尝试解析字符串 try: # 先尝试作为数字解析 et_float = float(et) entry_ts_ms = int(et_float * 1000) if et_float < 3000000000 else int(et_float) except ValueError: # 尝试作为日期字符串解析 try: from datetime import datetime dt = datetime.strptime(et, "%Y-%m-%d %H:%M:%S") entry_ts_ms = int(dt.timestamp() * 1000) except: pass except Exception: pass # 优化:如果已知入场时间,则仅查询入场之后的订单 if entry_ts_ms > 0: start_time = max(start_time, entry_ts_ms) else: # 如果没有入场时间(如手动录入且未填时间),缩小搜索范围以防止匹配到很久以前的订单 # 限制为最近1小时,避免匹配到几天的订单导致"时间倒流" start_time = end_time - (60 * 60 * 1000) logger.warning(f"{symbol} [状态同步] ⚠️ 交易记录缺少入场时间,将搜索范围限制为最近1小时") logger.debug( f"{symbol} [状态同步] 获取历史订单: " f"symbol={symbol}, startTime={start_time}, endTime={end_time}" ) # 获取历史订单 orders = await self.client.client.futures_get_all_orders( symbol=symbol, startTime=start_time, endTime=end_time, recvWindow=20000 ) # 验证 orders 的类型 if not isinstance(orders, list): logger.warning( f"{symbol} [状态同步] ⚠️ futures_get_all_orders 返回的不是列表: " f"类型={type(orders)}, 值={orders}" ) orders = [] # 设置为空列表,避免后续错误 if not orders: logger.debug(f"{symbol} [状态同步] 未找到历史订单") else: logger.debug(f"{symbol} [状态同步] 找到 {len(orders)} 个历史订单") # 查找最近的平仓订单(reduceOnly=True且已成交) close_orders = [] for o in orders: try: if isinstance(o, dict) and o.get('reduceOnly') == True and o.get('status') == 'FILLED': # 再次过滤掉早于入场时间的订单(双重保险) order_time = int(o.get('updateTime', 0)) if entry_ts_ms > 0 and order_time < entry_ts_ms: continue # 如果没有入场时间,且订单时间早于1小时前(虽然startTime已限制,但双重检查),则跳过 if entry_ts_ms == 0 and order_time < (end_time - 3600000): continue close_orders.append(o) except (AttributeError, TypeError) as e: logger.warning( f"{symbol} [状态同步] ⚠️ 处理订单数据时出错: " f"错误类型={type(e).__name__}, 错误消息={str(e)}, " f"订单数据类型={type(o)}, 订单数据={o}" ) continue if close_orders: # 按时间倒序排序,取最近的 close_orders.sort(key=lambda x: x.get('updateTime', 0), reverse=True) latest_order = close_orders[0] # 获取平均成交价格 exit_price = float(latest_order.get('avgPrice', 0)) if exit_price > 0: logger.info(f"{symbol} [状态同步] 从币安历史订单获取平仓价格: {exit_price:.4f} USDT") else: logger.warning( f"{symbol} [状态同步] 历史订单中没有有效的avgPrice: {latest_order}" ) except KeyError as key_error: # KeyError 可能是访问 orders[symbol] 或其他字典访问错误 logger.error( f"{symbol} [状态同步] ❌ 获取历史订单时KeyError: " f"错误key={key_error}, 错误类型={type(key_error).__name__}" ) import traceback logger.debug(f"{symbol} [状态同步] KeyError详情:\n{traceback.format_exc()}") except Exception as order_error: logger.warning( f"{symbol} [状态同步] 获取历史订单失败: " f"错误类型={type(order_error).__name__}, 错误消息={str(order_error)}" ) import traceback logger.debug(f"{symbol} [状态同步] 错误详情:\n{traceback.format_exc()}") # 如果无法从订单获取,尝试使用当前价格;若当前价格也无法获取,则保留open状态等待下次同步 if not exit_price or exit_price <= 0: try: ticker = await self.client.get_ticker_24h(symbol) if ticker and ticker.get('price'): exit_price = float(ticker['price']) logger.warning(f"{symbol} [状态同步] 使用当前价格作为平仓价格: {exit_price:.4f} USDT") else: logger.warning( f"{symbol} [状态同步] 无法获取当前价格(ticker={ticker})," f"保留open状态等待下次同步" ) continue except KeyError as key_error: # KeyError 可能是访问 ticker['price'] 时出错 logger.error( f"{symbol} [状态同步] ❌ 获取当前价格时KeyError: {key_error}, " f"ticker数据: {ticker if 'ticker' in locals() else 'N/A'}" ) continue except Exception as ticker_error: logger.warning( f"{symbol} [状态同步] 获取当前价格失败: " f"错误类型={type(ticker_error).__name__}, 错误消息={str(ticker_error)}," f"将保留open状态等待下次同步" ) continue # 计算盈亏(确保所有值都是float类型,避免Decimal类型问题) try: entry_price = float(trade.get('entry_price', 0)) quantity = float(trade.get('quantity', 0)) if entry_price <= 0 or quantity <= 0: logger.error( f"{symbol} [状态同步] ❌ 交易记录数据无效: " f"入场价={entry_price}, 数量={quantity}, 跳过更新" ) continue # 如果已经获取到精确的 Realized PnL,直接使用 if realized_pnl is not None: pnl = realized_pnl # 根据 Realized PnL 反推百分比 (PnL / 保证金) 或者 (PnL / 名义价值)? # 这里保持与价格差计算一致的逻辑:(PriceDiff / EntryPrice) * 100 # 如果 exit_price 是计算出来的均价,这个公式依然成立 if trade.get('side') == 'BUY': pnl_percent = ((exit_price - entry_price) / entry_price) * 100 else: pnl_percent = ((entry_price - exit_price) / entry_price) * 100 else: # 降级方案:使用价格差计算 if trade.get('side') == 'BUY': pnl = (exit_price - entry_price) * quantity pnl_percent = ((exit_price - entry_price) / entry_price) * 100 else: # SELL pnl = (entry_price - exit_price) * quantity pnl_percent = ((entry_price - exit_price) / entry_price) * 100 logger.debug( f"{symbol} [状态同步] 盈亏计算: " f"入场价={entry_price:.4f}, 平仓价={exit_price:.4f}, " f"数量={quantity:.4f}, 方向={trade.get('side', 'N/A')}, " f"盈亏={pnl:.2f} USDT ({pnl_percent:.2f}%)" ) except (ValueError, TypeError, KeyError) as calc_error: logger.error( f"{symbol} [状态同步] ❌ 计算盈亏失败 (ID: {trade_id}): " f"错误类型={type(calc_error).__name__}, 错误消息={str(calc_error)}, " f"交易记录数据: entry_price={trade.get('entry_price', 'N/A')}, " f"quantity={trade.get('quantity', 'N/A')}, side={trade.get('side', 'N/A')}" ) continue # 从历史订单中获取平仓订单号 exit_order_id = None latest_close_order = None if close_orders: exit_order_id = close_orders[0].get('orderId') latest_close_order = close_orders[0] if exit_order_id: logger.info(f"{symbol} [状态同步] 找到平仓订单号: {exit_order_id}") # 使用 try-except 包裹,确保异常被正确处理 try: # 计算持仓持续时间和策略类型 # exit_reason 细分:优先用价格匹配和特征判断,其次看币安订单类型 exit_reason = "sync" exit_time_ts = None is_reduce_only = False try: if latest_close_order and isinstance(latest_close_order, dict): otype = str( latest_close_order.get("type") or latest_close_order.get("origType") or "" ).upper() is_reduce_only = latest_close_order.get("reduceOnly", False) ms = latest_close_order.get("updateTime") or latest_close_order.get("time") try: if ms: exit_time_ts = int(int(ms) / 1000) except Exception: exit_time_ts = None except Exception: pass # ⚠️ 关键改进:优先使用价格匹配和特征判断(提高准确性) # 这对于保护单触发的 MARKET 订单特别重要(币安的保护单触发后会生成 MARKET 订单,但 reduceOnly 可能不准确) try: def _close_to(a: float, b: float, max_pct: float = 0.10) -> bool: # 从5%放宽到10%,以应对极端滑点 if a <= 0 or b <= 0: return False return abs((a - b) / b) <= max_pct ep = float(exit_price or 0) entry_price_val = float(trade.get("entry_price", 0) or 0) sl = trade.get("stop_loss_price") tp = trade.get("take_profit_price") tp1 = trade.get("take_profit_1") tp2 = trade.get("take_profit_2") # 计算持仓时间和亏损比例(用于特征判断) entry_time = trade.get("entry_time") duration_minutes = None if entry_time and exit_time_ts: try: duration_minutes = (exit_time_ts - int(entry_time)) / 60.0 except Exception: pass # ⚠️ 关键修复:使用基于保证金的盈亏百分比判断盈利/亏损,而不是价格涨跌幅 # 因为价格涨跌幅(如2.35%)和基于保证金的盈亏百分比(如18.82%)差异很大 # 对于SELL单,价格从0.119跌到0.1162,价格涨跌幅只有2.35%,但基于保证金的盈亏百分比是18.82% pnl_percent_price = float(trade.get("pnl_percent", 0) or 0) # 价格涨跌幅(数据库可能存储的是这个) # 重新计算基于保证金的盈亏百分比(更准确) leverage = float(trade.get("leverage", 8) or 8) entry_value = entry_price * quantity margin = entry_value / leverage if leverage > 0 else entry_value pnl_percent_margin = (pnl / margin * 100) if margin > 0 else 0 # 使用基于保证金的盈亏百分比判断(与实时监控逻辑一致) pnl_percent_for_judge = pnl_percent_margin # ⚠️ 2026-01-27关键修复:优先检查盈亏情况,避免盈利单被错误标记为止损 # 1. 优先检查盈亏情况(使用基于保证金的盈亏百分比) if pnl_percent_for_judge > 0: # 盈利单:优先检查止盈价格匹配 if tp is not None and _close_to(ep, float(tp), max_pct=0.10): exit_reason = "take_profit" elif tp1 is not None and _close_to(ep, float(tp1), max_pct=0.10): exit_reason = "take_profit" elif tp2 is not None and _close_to(ep, float(tp2), max_pct=0.10): exit_reason = "take_profit" # 如果盈利但没有匹配止盈价,可能是移动止损或手动平仓 elif exit_reason == "sync": # 检查是否有移动止损标记 if is_reduce_only: exit_reason = "trailing_stop" # 可能是移动止损 else: exit_reason = "manual" # 可能是手动平仓 else: # 亏损单:检查止损价格匹配 if sl is not None and entry_price_val > 0 and ep > 0: sl_val = float(sl) # 价格匹配:平仓价接近止损价 if _close_to(ep, sl_val, max_pct=0.10): exit_reason = "stop_loss" # 方向匹配:BUY时平仓价 < 止损价,SELL时平仓价 > 止损价 elif (trade.get("side") == "BUY" and ep < sl_val) or (trade.get("side") == "SELL" and ep > sl_val): # 如果价格在止损方向,且亏损比例较大,更可能是止损触发 # ⚠️ 关键修复:使用基于保证金的盈亏百分比判断 if pnl_percent_for_judge < -5.0: # 亏损超过5%(基于保证金) exit_reason = "stop_loss" logger.info(f"{trade.get('symbol')} [同步] 价格方向匹配止损,且亏损{pnl_percent_for_judge:.2f}% of margin,标记为止损") # 2. 如果仍未确定,检查止盈价格匹配(作为备选);仅盈利单可标为止盈 if exit_reason == "sync" and ep > 0 and pnl_percent_for_judge > 0: if tp is not None and _close_to(ep, float(tp), max_pct=0.10): exit_reason = "take_profit" elif tp1 is not None and _close_to(ep, float(tp1), max_pct=0.10): exit_reason = "take_profit" elif tp2 is not None and _close_to(ep, float(tp2), max_pct=0.10): exit_reason = "take_profit" # 3. 特征判断:如果价格不匹配,但满足止损特征,也标记为止损 if exit_reason == "sync" and entry_price_val > 0 and ep > 0: # 特征1:持仓时间短(< 30分钟)且亏损 # ⚠️ 关键修复:使用基于保证金的盈亏百分比判断 if duration_minutes and duration_minutes < 30 and pnl_percent_for_judge < -5.0: # 特征2:价格在止损方向 if sl is not None: sl_val = float(sl) if (trade.get("side") == "BUY" and ep < sl_val) or (trade.get("side") == "SELL" and ep > sl_val): exit_reason = "stop_loss" logger.info(f"{trade.get('symbol')} [同步] 特征判断:持仓{duration_minutes:.1f}分钟,亏损{pnl_percent_for_judge:.2f}% of margin,价格在止损方向,标记为止损") # ⚠️ 2026-02-05 修复:防止亏损单被错误标记为止盈 if pnl_percent_for_judge < 0 and exit_reason == "take_profit": logger.warning(f"{symbol} [状态同步] ⚠️ 亏损单({pnl_percent_for_judge:.2f}%)被标记为止盈,强制修正为manual/stop_loss") # 如果亏损较大,归为止损 if pnl_percent_for_judge < -5.0: exit_reason = "stop_loss" else: exit_reason = "manual" # 4. 如果之前标记为 sync 且是 reduceOnly 订单,但价格不匹配止损/止盈,可能是其他自动平仓(如移动止损) if exit_reason == "sync" and is_reduce_only: # 检查是否是移动止损:如果价格接近入场价,可能是移动止损触发的 if entry_price_val > 0 and _close_to(ep, entry_price_val, max_pct=0.01): exit_reason = "trailing_stop" # 5. 最后才看币安订单类型(作为兜底);亏损单不标为止盈 if exit_reason == "sync" and latest_close_order and isinstance(latest_close_order, dict): otype = str( latest_close_order.get("type") or latest_close_order.get("origType") or "" ).upper() if "TRAILING" in otype: exit_reason = "trailing_stop" elif "TAKE_PROFIT" in otype and pnl_percent_for_judge > 0: exit_reason = "take_profit" elif "TAKE_PROFIT" in otype and pnl_percent_for_judge < 0: exit_reason = "stop_loss" if pnl_percent_for_judge < -5.0 else "manual" elif "STOP" in otype: exit_reason = "stop_loss" elif otype in ("MARKET", "LIMIT"): # 只有在价格和特征都不匹配,且不是 reduceOnly 时,才标记为手动平仓 if not is_reduce_only: # 再次检查:如果亏损很大,更可能是止损触发(币安API可能不准确) # ⚠️ 关键修复:使用基于保证金的盈亏百分比判断 if pnl_percent_for_judge < -10.0: exit_reason = "stop_loss" # 大额亏损,更可能是止损 logger.warning(f"{trade.get('symbol')} [同步] 大额亏损{pnl_percent_for_judge:.2f}% of margin,即使reduceOnly=false也标记为止损") else: exit_reason = "manual" except Exception as e: logger.warning(f"判断平仓原因失败: {e}") pass # 持仓持续时间(分钟):优先用币安订单时间,否则用当前时间 entry_time = trade.get("entry_time") duration_minutes = None try: et = None if isinstance(entry_time, (int, float)): et = int(entry_time) elif isinstance(entry_time, str) and entry_time.strip(): # 兼容旧格式:字符串时间戳/日期字符串 s = entry_time.strip() if s.isdigit(): et = int(s) else: from datetime import datetime et = int(datetime.fromisoformat(s).timestamp()) xt = int(exit_time_ts) if exit_time_ts is not None else int(get_beijing_time().timestamp()) if et is not None and xt >= et: duration_minutes = int((xt - et) / 60) except Exception as e: logger.debug(f"计算持仓持续时间失败: {e}") strategy_type = 'trend_following' # 默认策略类型 # ⚠️ 关键修复:使用基于保证金的盈亏百分比更新数据库(与实时监控逻辑一致) Trade.update_exit( trade_id=trade_id, exit_price=exit_price, exit_reason=exit_reason, pnl=pnl, pnl_percent=pnl_percent_margin, # 使用基于保证金的盈亏百分比 exit_order_id=exit_order_id, # 保存币安平仓订单号 strategy_type=strategy_type, duration_minutes=duration_minutes, exit_time_ts=exit_time_ts, ) except Exception as update_error: # update_exit 内部已经有异常处理,但如果仍然失败,记录错误但不中断同步流程 error_str = str(update_error) if "Duplicate entry" in error_str and "exit_order_id" in error_str: logger.warning( f"{symbol} [状态同步] ⚠️ exit_order_id {exit_order_id} 唯一约束冲突," f"update_exit 内部处理失败,尝试不更新 exit_order_id" ) # 再次尝试,不更新 exit_order_id try: from database.connection import db # from database.models import get_beijing_time # 移除本地导入,避免 UnboundLocalError exit_time = int(exit_time_ts) if exit_time_ts is not None else get_beijing_time() db.execute_update( """UPDATE trades SET exit_price = %s, exit_time = %s, exit_reason = %s, pnl = %s, pnl_percent = %s, status = 'closed' WHERE id = %s""", (exit_price, exit_time, exit_reason, pnl, pnl_percent, trade_id) ) logger.info(f"{symbol} [状态同步] ✓ 已更新(跳过 exit_order_id)") except Exception as retry_error: logger.error(f"{symbol} [状态同步] ❌ 重试更新也失败: {retry_error}") raise else: # 其他错误,重新抛出 raise logger.info( f"{symbol} [状态同步] ✓ 已更新 (ID: {trade_id}, " f"盈亏: {pnl:.2f} USDT, {pnl_percent:.2f}%)" ) # 清理本地记录 if symbol in self.active_positions: await self._stop_position_monitoring(symbol) del self.active_positions[symbol] logger.debug(f"{symbol} [状态同步] 已清理本地持仓记录") except Exception as e: # 详细记录错误信息,包括异常类型、错误消息和堆栈跟踪 import traceback error_type = type(e).__name__ error_msg = str(e) error_traceback = traceback.format_exc() logger.error( f"{symbol} [状态同步] ❌ 更新失败 (ID: {trade_id}): " f"错误类型={error_type}, 错误消息={error_msg}" ) logger.debug( f"{symbol} [状态同步] 错误详情:\n{error_traceback}" ) # 如果是数据库相关错误,记录更多信息 if "Duplicate entry" in error_msg or "1062" in error_msg: logger.error( f"{symbol} [状态同步] 数据库唯一约束冲突," f"可能原因: exit_order_id={exit_order_id} 已被其他交易记录使用" ) elif "database" in error_msg.lower() or "connection" in error_msg.lower(): logger.error( f"{symbol} [状态同步] 数据库连接或执行错误," f"请检查数据库连接状态" ) else: logger.info("✓ 持仓状态同步完成,数据库与币安状态一致") # 5. 检查币安有但数据库没有记录的持仓(可能是本策略开仓后未正确落库、或其它来源) missing_in_db = binance_symbols - db_open_symbols if missing_in_db: logger.info( f"[账号{self.account_id}] 发现 {len(missing_in_db)} 个持仓在币安存在但数据库中没有 open 记录: " f"{', '.join(missing_in_db)}" ) sync_create_manual = config.TRADING_CONFIG.get("SYNC_CREATE_MANUAL_ENTRY_RECORD", False) sync_recover = config.TRADING_CONFIG.get("SYNC_RECOVER_MISSING_POSITIONS", True) sync_recover_only_has_sltp = config.TRADING_CONFIG.get("SYNC_RECOVER_ONLY_WHEN_HAS_SLTP", True) # 订单统一由自动下单入 DB:同步/持仓 sync 不创建新记录,仅 WS 与自动开仓写库 only_auto_creates = config.TRADING_CONFIG.get("ONLY_AUTO_TRADE_CREATES_RECORDS", True) if only_auto_creates: sync_recover = False sync_create_manual = False if missing_in_db: logger.info( f"[账号{self.account_id}] 因 ONLY_AUTO_TRADE_CREATES_RECORDS=True 已关闭补建," f"共 {len(missing_in_db)} 个仅币安持仓未写入 DB。" " 若要让币安与 DB 一致,请设 ONLY_AUTO_TRADE_CREATES_RECORDS=False 且 SYNC_RECOVER_MISSING_POSITIONS=True,然后重启或等待下次同步。" ) if sync_recover: system_order_prefix = (config.TRADING_CONFIG.get("SYSTEM_ORDER_ID_PREFIX") or "").strip() if system_order_prefix: logger.info( f" → 补建缺失持仓:一律写入 DB、自动挂止损止盈并纳入监控(含 clientOrderId 非 {system_order_prefix!r} 前缀的来历不明仓)" ) else: logger.info(" → 补建缺失持仓:一律写入 DB、自动挂止损止盈并纳入监控(含无 SL/TP 的仓位)") for symbol in missing_in_db: try: binance_position = next((p for p in binance_positions if p.get("symbol") == symbol), None) if not binance_position or float(binance_position.get("positionAmt", 0)) == 0: continue position_amt = float(binance_position["positionAmt"]) quantity = abs(position_amt) side = "BUY" if position_amt > 0 else "SELL" entry_price = float(binance_position.get("entryPrice", 0)) notional = quantity * entry_price if notional < 1.0: continue # 补建时尽量拿到 entry_order_id:优先用 WS 缓存的 FILLED 消息(基于消息补建),否则按时间范围查开仓订单 entry_order_id = None client_order_id_sync = None try: rc = getattr(self.client, "redis_cache", None) if rc: cache_key = f"ats:filled_open_orders:{self.account_id}" cached = await rc.hget(cache_key, symbol) if cached and isinstance(cached, dict): cs = (cached.get("side") or "").strip().upper() ap = float(cached.get("avgPrice") or 0) eq = float(cached.get("executedQty") or 0) if (cs == side and ap > 0 and abs(ap - entry_price) / max(entry_price, 1e-9) < 0.01 and abs(eq - quantity) < 1e-6): entry_order_id = cached.get("orderId") client_order_id_sync = (cached.get("clientOrderId") or "").strip() or None if entry_order_id: logger.debug(f" {symbol} 补建从 WS 缓存 FILLED 取得 orderId={entry_order_id}, clientOrderId={client_order_id_sync!r}") except Exception as e: logger.debug(f" {symbol} 补建读 WS 缓存失败: {e}") if system_order_prefix and entry_order_id is None: try: end_ms = int(time.time() * 1000) start_ms = end_ms - (24 * 3600 * 1000) orders = await self.client.client.futures_get_all_orders( symbol=symbol, startTime=start_ms, endTime=end_ms, recvWindow=20000 ) if isinstance(orders, list): open_orders = [ o for o in orders if isinstance(o, dict) and o.get("reduceOnly") is False and str(o.get("side", "")).upper() == side and o.get("status") == "FILLED" ] our_orders = [o for o in open_orders if (o.get("clientOrderId") or "").startswith(system_order_prefix)] if our_orders: our_orders.sort(key=lambda x: int(x.get("updateTime", 0)), reverse=True) best = None for o in our_orders: ap = float(o.get("avgPrice") or 0) eq = float(o.get("executedQty") or o.get("origQty") or 0) if ap > 0 and abs(ap - entry_price) / max(entry_price, 1e-9) < 0.01 and abs(eq - quantity) < 1e-6: best = o break if best is None: best = our_orders[0] entry_order_id = best.get("orderId") client_order_id_sync = (best.get("clientOrderId") or "").strip() or None logger.debug(f" {symbol} 补建从 get_all_orders 取得 orderId={entry_order_id}, clientOrderId={client_order_id_sync!r}") except Exception as e: logger.debug(f" {symbol} 补建 get_all_orders 取开仓订单号失败: {e}") if entry_order_id is None: try: recent = await self.client.get_recent_trades(symbol, limit=100) if not recent: await asyncio.sleep(2) recent = await self.client.get_recent_trades(symbol, limit=100) if recent: same_side = [t for t in recent if str(t.get("side", "")).upper() == side] same_side.sort(key=lambda x: int(x.get("time", 0)), reverse=True) if system_order_prefix and same_side: for t in same_side[:5]: oid = t.get("orderId") if not oid: continue try: info = await self.client.client.futures_get_order(symbol=symbol, orderId=int(oid), recvWindow=20000) cid = (info or {}).get("clientOrderId") or "" if cid.startswith(system_order_prefix): entry_order_id = oid client_order_id_sync = cid.strip() or None break except Exception: continue if entry_order_id is None and same_side: entry_order_id = same_side[0].get("orderId") except Exception: pass if entry_order_id and client_order_id_sync is None: try: order_info = await self.client.client.futures_get_order(symbol=symbol, orderId=int(entry_order_id), recvWindow=20000) cid = (order_info or {}).get("clientOrderId") or "" client_order_id_sync = cid.strip() or None except Exception: pass # 标记是否「来历不明」:用于 DB entry_reason 与后续统计分析(sync_recovered_unknown_origin) sync_unknown_origin = False is_clearly_manual = False if system_order_prefix and entry_order_id and client_order_id_sync and not client_order_id_sync.startswith(system_order_prefix): is_clearly_manual = True logger.debug(f" {symbol} 开仓订单 clientOrderId={client_order_id_sync!r} 非系统前缀,将按来历不明仓补建并挂 SL/TP") elif system_order_prefix and entry_order_id and not client_order_id_sync: try: order_info = await self.client.client.futures_get_order(symbol=symbol, orderId=int(entry_order_id), recvWindow=20000) cid = (order_info or {}).get("clientOrderId") or "" if cid and not cid.startswith(system_order_prefix): is_clearly_manual = True except Exception: pass if system_order_prefix: if is_clearly_manual and config.TRADING_CONFIG.get("SYNC_MONITOR_BINANCE_POSITIONS_WITH_SLTP", True): if await self._symbol_has_sltp_orders(symbol): is_clearly_manual = False logger.info(f" → {symbol} 开仓订单 clientOrderId 非系统前缀,但存在止损/止盈单,按系统单补建并监控") if is_clearly_manual: sync_unknown_origin = True logger.info(f" → {symbol} 来历不明(开仓订单非系统前缀),将补建记录、自动挂止损止盈并纳入监控") else: if sync_recover_only_has_sltp and not (await self._symbol_has_sltp_orders(symbol)): sync_unknown_origin = True logger.info(f" → {symbol} 无止损/止盈单,将补建记录、自动挂止损止盈并纳入监控") # 不再因 is_clearly_manual 或 无 SL/TP 跳过,一律补建 + 挂 SL/TP + 监控 entry_reason_sync = "sync_recovered_unknown_origin" if sync_unknown_origin else "sync_recovered" # 同一开仓订单在 DB 中已有记录(无论 open 或 closed)则跳过,避免重复建单(如 PYTHUSDT 同单多笔) if entry_order_id and hasattr(Trade, "get_by_entry_order_id"): try: existing = Trade.get_by_entry_order_id(entry_order_id) if existing and existing.get("symbol") == symbol: logger.debug(f" {symbol} 开仓订单 {entry_order_id} 已有记录 (id={existing.get('id')}, status={existing.get('status')}),跳过补建") continue except Exception: pass # 从币安取真实开仓时间,避免补建后开仓时间全是“当前时间” entry_time_ts = None if entry_order_id: try: oi = await self.client.client.futures_get_order(symbol=symbol, orderId=int(entry_order_id), recvWindow=20000) if oi and oi.get("time"): entry_time_ts = int(oi["time"]) // 1000 except Exception: pass if entry_time_ts is None: try: recent = await self.client.get_recent_trades(symbol, limit=100) same_side = [t for t in (recent or []) if str(t.get("side", "")).upper() == side] if same_side: same_side.sort(key=lambda x: int(x.get("time", 0))) entry_time_ts = int(same_side[0].get("time", 0)) // 1000 except Exception: pass try: trade_id = Trade.create( symbol=symbol, side=side, quantity=quantity, entry_price=entry_price, leverage=binance_position.get("leverage", 10), entry_reason=entry_reason_sync, entry_order_id=entry_order_id, client_order_id=client_order_id_sync, notional_usdt=notional, margin_usdt=(notional / float(binance_position.get("leverage", 10) or 10)) if float(binance_position.get("leverage", 10) or 0) > 0 else None, account_id=self.account_id, entry_time=entry_time_ts, ) logger.info(f" ✓ [DB] {symbol} [状态同步] 补建记录已落库 ID={trade_id} orderId={entry_order_id or '-'} entry_reason={entry_reason_sync}") except Exception as create_ex: logger.error( f" [DB] {symbol} [状态同步] 补建写入 DB 失败,该持仓将无法纳入监控: " f"orderId={entry_order_id} error_type={type(create_ex).__name__} error={create_ex}" ) _log_trade_db_failure( symbol=symbol, entry_order_id=entry_order_id, side=side, quantity=quantity, entry_price=entry_price, account_id=self.account_id, reason="sync_recover_create_failed", error_type=type(create_ex).__name__, error_message=str(create_ex) ) raise ticker = await self.client.get_ticker_24h(symbol) current_price = ticker["price"] if ticker else entry_price lev = float(binance_position.get("leverage", 10)) # ---------- 补建 SL/TP 执行顺序(必须严格保证)---------- # 1) 先读交易所当前条件单的止损/止盈,再决定用哪套价格,最后才调用 _ensure_exchange_sltp_orders。 # 否则会先用 risk_manager 算出初始止损,挂到交易所,把已有保本/移动止损覆盖掉。 # 2) 若交易所止损已达保本或更优 → position_info 采用交易所止损并设 breakevenStopSet=True; # 否则采用 risk_manager 初始止损。止盈:交易所有则用交易所,否则用 risk_manager。 # 3) _ensure_exchange_sltp_orders 用当前 position_info 的 SL/TP 挂单,因此传入的必须是上面决定好的价格。 sl_from_ex, tp_from_ex = await self._get_sltp_from_exchange(symbol, side) breakeven = self._breakeven_stop_price(entry_price, side, None) use_exchange_sl = False if sl_from_ex is not None: if side == "BUY" and sl_from_ex >= breakeven: use_exchange_sl = True elif side == "SELL" and sl_from_ex <= breakeven: use_exchange_sl = True stop_loss_pct = config.TRADING_CONFIG.get("STOP_LOSS_PERCENT", 0.08) if stop_loss_pct is not None and stop_loss_pct > 1: stop_loss_pct = stop_loss_pct / 100.0 take_profit_pct = config.TRADING_CONFIG.get("TAKE_PROFIT_PERCENT", 0.15) if take_profit_pct is not None and take_profit_pct > 1: take_profit_pct = take_profit_pct / 100.0 if not take_profit_pct: take_profit_pct = (stop_loss_pct or 0.08) * 2.0 if use_exchange_sl: stop_loss_price = sl_from_ex initial_stop_loss = self.risk_manager.get_stop_loss_price(entry_price, side, quantity, lev, stop_loss_pct=stop_loss_pct) logger.info(f" {symbol} [补建] 使用交易所已有止损(保本/移动)sl={stop_loss_price},不覆盖为初始止损 {initial_stop_loss}") else: stop_loss_price = self.risk_manager.get_stop_loss_price(entry_price, side, quantity, lev, stop_loss_pct=stop_loss_pct) initial_stop_loss = stop_loss_price if tp_from_ex is not None: take_profit_price = tp_from_ex else: take_profit_price = self.risk_manager.get_take_profit_price(entry_price, side, quantity, lev, take_profit_pct=take_profit_pct) position_info = { "symbol": symbol, "side": side, "quantity": quantity, "entryPrice": entry_price, "changePercent": 0, "orderId": entry_order_id, "tradeId": trade_id, "stopLoss": stop_loss_price, "takeProfit": take_profit_price, "initialStopLoss": initial_stop_loss, "leverage": lev, "entryReason": entry_reason_sync, "atr": None, "maxProfit": 0.0, "trailingStopActivated": False, "breakevenStopSet": use_exchange_sl, "entryTime": entry_time_ts if entry_time_ts is not None else get_beijing_time(), } self.active_positions[symbol] = position_info # 4) 最后再同步到交易所:用已决定好的 position_info(可能是交易所保本或 risk_manager 初始)挂单 try: await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price) except Exception as sltp_e: logger.warning(f" {symbol} [补建] 补挂交易所止损止盈失败(不影响监控): {sltp_e}") if self._monitoring_enabled: await self._start_position_monitoring(symbol) except Exception as e: logger.warning(f" ✗ {symbol} [状态同步] 补建失败: {e}") elif not sync_create_manual: logger.info( f"[账号{self.account_id}] → 已跳过自动创建交易记录(SYNC_RECOVER_MISSING_POSITIONS 未开启或 ONLY_AUTO_TRADE_CREATES_RECORDS=True)。" " 若要让币安持仓与 DB 一致:设 ONLY_AUTO_TRADE_CREATES_RECORDS=False、SYNC_RECOVER_MISSING_POSITIONS=True,重启或等待下次同步。" ) elif sync_create_manual: # 为手动开仓的持仓创建数据库记录并启动监控(仅当显式开启且未走上面的「补建系统单」时) for symbol in missing_in_db: try: # 获取币安持仓详情 binance_position = next( (p for p in binance_positions if p['symbol'] == symbol), None ) if not binance_position: continue position_amt = binance_position['positionAmt'] entry_price = binance_position['entryPrice'] quantity = abs(position_amt) side = 'BUY' if position_amt > 0 else 'SELL' notional = (float(entry_price) * float(quantity)) if entry_price and quantity else 0 if notional < 1.0: logger.debug(f"{symbol} [状态同步] 跳过灰尘持仓 (名义 {notional:.4f} USDT < 1),不创建记录") continue logger.info( f"{symbol} [状态同步] 检测到手动开仓,创建数据库记录... " f"({side} {quantity:.4f} @ {entry_price:.4f})" ) # 尽量从币安成交取 entry_order_id 与真实开仓时间(limit=100、空时重试一次) entry_order_id = None entry_time_ts = None try: recent = await self.client.get_recent_trades(symbol, limit=100) if not recent: await asyncio.sleep(2) recent = await self.client.get_recent_trades(symbol, limit=100) if recent: same_side = [t for t in recent if str(t.get('side', '')).upper() == side] if same_side: same_side.sort(key=lambda x: int(x.get('time', 0)), reverse=True) entry_order_id = same_side[0].get('orderId') # 开仓时间取同方向最早一笔成交(oldest),避免显示为“当前时间” same_side_asc = sorted(same_side, key=lambda x: int(x.get('time', 0))) entry_time_ts = int(same_side_asc[0].get('time', 0)) // 1000 except Exception: pass if entry_time_ts is None and entry_order_id: try: oi = await self.client.client.futures_get_order(symbol=symbol, orderId=int(entry_order_id), recvWindow=20000) if oi and oi.get('time'): entry_time_ts = int(oi['time']) // 1000 except Exception: pass # 创建数据库记录(显式传入 account_id、真实开仓时间) try: trade_id = Trade.create( symbol=symbol, side=side, quantity=quantity, entry_price=entry_price, leverage=binance_position.get('leverage', 10), entry_reason='manual_entry', # 标记为手动开仓 entry_order_id=entry_order_id, notional_usdt=notional, margin_usdt=(notional / float(binance_position.get('leverage', 10) or 10)) if float(binance_position.get('leverage', 10) or 0) > 0 else None, account_id=self.account_id, entry_time=entry_time_ts, ) logger.info(f"[DB] {symbol} [状态同步] 手动开仓记录已落库 ID={trade_id} orderId={entry_order_id or '-'}") except Exception as create_ex: logger.error( f"[DB] {symbol} [状态同步] 手动开仓写入 DB 失败: " f"orderId={entry_order_id} error_type={type(create_ex).__name__} error={create_ex}" ) _log_trade_db_failure( symbol=symbol, entry_order_id=entry_order_id, side=side, quantity=quantity, entry_price=entry_price, account_id=self.account_id, reason="manual_entry_create_failed", error_type=type(create_ex).__name__, error_message=str(create_ex) ) continue # 创建本地持仓记录(用于监控) ticker = await self.client.get_ticker_24h(symbol) current_price = ticker['price'] if ticker else entry_price # ---------- 手动开仓补建 SL/TP 顺序:先读交易所 → 决定 SL/TP → 再写 position_info ---------- sl_from_ex, tp_from_ex = await self._get_sltp_from_exchange(symbol, side) breakeven = self._breakeven_stop_price(entry_price, side, None) use_exchange_sl = False if sl_from_ex is not None: if side == 'BUY' and sl_from_ex >= breakeven: use_exchange_sl = True elif side == 'SELL' and sl_from_ex <= breakeven: use_exchange_sl = True # 计算止损止盈(缺失时用 risk_manager 基于保证金) leverage = binance_position.get('leverage', 10) stop_loss_pct_margin = config.TRADING_CONFIG.get('STOP_LOSS_PERCENT', 0.08) if stop_loss_pct_margin is not None and stop_loss_pct_margin > 1: stop_loss_pct_margin = stop_loss_pct_margin / 100.0 take_profit_pct_margin = config.TRADING_CONFIG.get('TAKE_PROFIT_PERCENT', 0.15) if take_profit_pct_margin is not None and take_profit_pct_margin > 1: take_profit_pct_margin = take_profit_pct_margin / 100.0 if take_profit_pct_margin is None or take_profit_pct_margin == 0: take_profit_pct_margin = stop_loss_pct_margin * 2.0 if use_exchange_sl: stop_loss_price = sl_from_ex initial_stop_loss = self.risk_manager.get_stop_loss_price( entry_price, side, quantity, leverage, stop_loss_pct=stop_loss_pct_margin ) logger.info(f" {symbol} [补建-手动] 使用交易所已有止损(保本/移动)sl={stop_loss_price},不覆盖为初始止损 {initial_stop_loss}") else: stop_loss_price = self.risk_manager.get_stop_loss_price( entry_price, side, quantity, leverage, stop_loss_pct=stop_loss_pct_margin ) initial_stop_loss = stop_loss_price if tp_from_ex is not None: take_profit_price = tp_from_ex else: take_profit_price = self.risk_manager.get_take_profit_price( entry_price, side, quantity, leverage, take_profit_pct=take_profit_pct_margin ) position_info = { 'symbol': symbol, 'side': side, 'quantity': quantity, 'entryPrice': entry_price, 'changePercent': 0, 'orderId': entry_order_id, 'tradeId': trade_id, 'stopLoss': stop_loss_price, 'takeProfit': take_profit_price, 'initialStopLoss': initial_stop_loss, 'leverage': leverage, 'entryReason': 'manual_entry', 'entryTime': entry_time_ts if entry_time_ts is not None else get_beijing_time(), 'atr': None, 'maxProfit': 0.0, 'trailingStopActivated': False, 'breakevenStopSet': use_exchange_sl } self.active_positions[symbol] = position_info # 启动WebSocket监控 if self._monitoring_enabled: await self._start_position_monitoring(symbol) logger.info(f"[账号{self.account_id}] {symbol} [状态同步] ✓ 已启动实时监控") logger.info(f"{symbol} [状态同步] ✓ 手动开仓同步完成") except Exception as e: logger.error(f"{symbol} [状态同步] ❌ 处理手动开仓失败: {e}") import traceback logger.error(f" 错误详情:\n{traceback.format_exc()}") # 6. 同步挂单信息 (STOP_MARKET / TAKE_PROFIT_MARKET) if self.active_positions: logger.debug("正在同步持仓挂单信息...") try: tasks = [] symbols = list(self.active_positions.keys()) for symbol in symbols: tasks.append(self.client.get_open_orders(symbol)) results = await asyncio.gather(*tasks, return_exceptions=True) for symbol, orders in zip(symbols, results): if isinstance(orders, list): # Filter for relevant orders (SL/TP) conditional_orders = [] for o in orders: o_type = o.get('type') # 关注止盈止损单 if o_type in ['STOP_MARKET', 'TAKE_PROFIT_MARKET', 'STOP', 'TAKE_PROFIT']: conditional_orders.append({ 'orderId': o.get('orderId'), 'type': o_type, 'side': o.get('side'), 'stopPrice': float(o.get('stopPrice', 0)), 'price': float(o.get('price', 0)), 'origType': o.get('origType'), 'reduceOnly': o.get('reduceOnly'), 'status': o.get('status') }) if symbol in self.active_positions: self.active_positions[symbol]['openOrders'] = conditional_orders logger.debug(f"{symbol} 同步挂单: {len(conditional_orders)} 个") else: logger.warning(f"{symbol} 获取挂单失败: {orders}") except Exception as e: logger.error(f"同步挂单信息失败: {e}") logger.info("持仓状态同步完成") except Exception as e: logger.error(f"同步持仓状态失败: {e}") import traceback logger.error(f"错误详情:\n{traceback.format_exc()}") async def start_all_position_monitoring(self): """ 启动所有持仓的WebSocket实时监控 """ if not self._monitoring_enabled: logger.info("实时监控已禁用,跳过启动") return # WebSocket 现在直接使用 aiohttp,不需要检查 socket_manager if not self.client: logger.warning("客户端未初始化,无法启动实时监控") return # 获取当前所有持仓(与 sync 一致:仅本系统关心的持仓会进 active_positions) # 多账号时必须用 account_id 读缓存;若缓存返回数量少于本地记录则强制 REST 一次,避免误判「持仓已不存在」 positions = await self._get_open_positions() binance_symbols = {p['symbol'] for p in positions} active_symbols = set(self.active_positions.keys()) if active_symbols and len(binance_symbols) < len(active_symbols): try: rest_positions = await self.client.get_open_positions() if rest_positions and len(rest_positions) > len(positions): positions = rest_positions binance_symbols = {p['symbol'] for p in positions} logger.info(f"[账号{self.account_id}] 缓存持仓数少于本地记录,已用 REST 拉取完整持仓: {len(binance_symbols)} 个") except Exception as e: logger.debug(f"[账号{self.account_id}] REST 拉取完整持仓失败: {e}") sync_create_manual = config.TRADING_CONFIG.get("SYNC_CREATE_MANUAL_ENTRY_RECORD", False) logger.info(f"[账号{self.account_id}] 币安持仓: {len(binance_symbols)} 个 ({', '.join(binance_symbols) if binance_symbols else '无'})") logger.info(f"[账号{self.account_id}] 本地持仓记录: {len(active_symbols)} 个 ({', '.join(active_symbols) if active_symbols else '无'})") # 仅为本系统已有记录的持仓启动监控;若未开启「同步创建手动开仓记录」,则不为「仅币安有仓」创建临时记录或监控 # 例外:SYNC_MONITOR_BINANCE_POSITIONS_WITH_SLTP=True 时,对「仅币安有仓且存在止损/止盈单」的视为可监管(多为系统单),补建并监控 only_binance = binance_symbols - active_symbols monitor_binance_with_sltp = config.TRADING_CONFIG.get("SYNC_MONITOR_BINANCE_POSITIONS_WITH_SLTP", True) if only_binance and not sync_create_manual and not monitor_binance_with_sltp: logger.info(f"[账号{self.account_id}] 跳过 {len(only_binance)} 个仅币安持仓的监控(SYNC_CREATE_MANUAL_ENTRY_RECORD=False 且 SYNC_MONITOR_BINANCE_POSITIONS_WITH_SLTP=False): {', '.join(only_binance)}") for position in positions: symbol = position['symbol'] if symbol not in self._monitor_tasks: # 若不在 active_positions:开启「同步创建手动开仓」则全部接入;或开启「监控仅币安有仓」时也接入(有 SL/TP 的视为系统单,无 SL/TP 的补挂并监控,避免裸仓) if symbol not in self.active_positions: has_sltp = await self._symbol_has_sltp_orders(symbol) if monitor_binance_with_sltp else False should_create = sync_create_manual or monitor_binance_with_sltp if not should_create: continue if sync_create_manual: logger.warning(f"[账号{self.account_id}] {symbol} 在币安有持仓但不在本地记录中,可能是手动开仓,尝试创建记录...") elif has_sltp: logger.info(f"[账号{self.account_id}] {symbol} 仅币安有仓且存在止损/止盈单,按系统单接入监控并补建记录") else: logger.info(f"[账号{self.account_id}] {symbol} 仅币安有仓且无止损/止盈单,接入监控并补挂 SL/TP(避免裸仓)") try: entry_price = position.get('entryPrice', 0) position_amt = position['positionAmt'] quantity = abs(position_amt) side = 'BUY' if position_amt > 0 else 'SELL' # 创建临时记录用于监控 ticker = await self.client.get_ticker_24h(symbol) current_price = ticker['price'] if ticker else entry_price # 计算止损止盈(基于保证金) try: leverage = float(position.get('leverage', 10)) except (ValueError, TypeError): leverage = 10.0 # 优先从币安读取当前止损/止盈,避免用“初始止损”覆盖已有保本/移动止损 sl_from_ex, tp_from_ex = await self._get_sltp_from_exchange(symbol, side) if sl_from_ex is not None or tp_from_ex is not None: stop_loss_price = float(sl_from_ex) if sl_from_ex is not None else None take_profit_price = float(tp_from_ex) if tp_from_ex is not None else None else: stop_loss_price = None take_profit_price = None # 若当前已明显盈利,不用交易所初始止损,直接算锁利止损(避免重启后把 226.97 这类初始止损再挂回去) entry_price_f = float(entry_price) current_price_f = float(current_price or entry_price_f) margin = (entry_price_f * quantity) / leverage if leverage else (entry_price_f * quantity) if side == 'BUY': pnl_amount = (current_price_f - entry_price_f) * quantity else: pnl_amount = (entry_price_f - current_price_f) * quantity pnl_percent_margin = (pnl_amount / margin * 100) if margin > 0 else 0 trailing_activation = config.TRADING_CONFIG.get('TRAILING_STOP_ACTIVATION', 0.10) if trailing_activation and trailing_activation <= 1: trailing_activation = trailing_activation * 100 if pnl_percent_margin >= (trailing_activation or 10): protect_amount = max(margin * float(config.TRADING_CONFIG.get('TRAILING_STOP_PROTECT', 0.02) or 0.02), self._min_protect_amount_for_fees(margin, leverage)) if side == 'BUY': stop_loss_price = entry_price_f + (pnl_amount - protect_amount) / quantity stop_loss_price = max(stop_loss_price, self._breakeven_stop_price(entry_price_f, 'BUY')) else: stop_loss_price = entry_price_f - protect_amount / quantity stop_loss_price = max(stop_loss_price, self._breakeven_stop_price(entry_price_f, 'SELL')) logger.info(f"[账号{self.account_id}] {symbol} 接入监控时已盈利{pnl_percent_margin:.1f}%,使用锁利止损 {stop_loss_price:.4f} 替代交易所原止损") if take_profit_price is None: tp_pct = config.TRADING_CONFIG.get('TAKE_PROFIT_PERCENT', 0.15) or 0.15 if tp_pct > 1: tp_pct = tp_pct / 100.0 take_profit_price = self.risk_manager.get_take_profit_price(entry_price, side, quantity, leverage, take_profit_pct=tp_pct) if stop_loss_price is None or take_profit_price is None: stop_loss_pct_margin = config.TRADING_CONFIG.get('STOP_LOSS_PERCENT', 0.08) if stop_loss_pct_margin is not None and stop_loss_pct_margin > 1: stop_loss_pct_margin = stop_loss_pct_margin / 100.0 take_profit_pct_margin = config.TRADING_CONFIG.get('TAKE_PROFIT_PERCENT', 0.15) if take_profit_pct_margin is not None and take_profit_pct_margin > 1: take_profit_pct_margin = take_profit_pct_margin / 100.0 if take_profit_pct_margin is None or take_profit_pct_margin == 0: take_profit_pct_margin = (stop_loss_pct_margin or 0.05) * 2.0 if stop_loss_price is None: stop_loss_price = self.risk_manager.get_stop_loss_price( entry_price, side, quantity, leverage, stop_loss_pct=stop_loss_pct_margin or 0.05 ) if take_profit_price is None: take_profit_price = self.risk_manager.get_take_profit_price( entry_price, side, quantity, leverage, take_profit_pct=take_profit_pct_margin ) entry_reason = 'manual_entry_temp' if sync_create_manual else 'sync_recovered' position_info = { 'symbol': symbol, 'side': side, 'quantity': quantity, 'entryPrice': entry_price, 'changePercent': 0, 'orderId': None, 'tradeId': None, 'stopLoss': stop_loss_price, 'takeProfit': take_profit_price, 'initialStopLoss': stop_loss_price, 'leverage': leverage, 'entryReason': entry_reason, 'atr': None, 'maxProfit': 0.0, 'trailingStopActivated': False, 'breakevenStopSet': False } # 订单统一由自动下单入 DB,此处仅做内存监控不创建 DB 记录 if not sync_create_manual and DB_AVAILABLE and Trade and not config.TRADING_CONFIG.get("ONLY_AUTO_TRADE_CREATES_RECORDS", True): try: notional = float(entry_price) * quantity trade_id = Trade.create( symbol=symbol, side=side, quantity=quantity, entry_price=entry_price, leverage=leverage, entry_reason="sync_recovered", entry_order_id=None, notional_usdt=notional, margin_usdt=(notional / leverage) if leverage else None, account_id=self.account_id, stop_loss_price=stop_loss_price, take_profit_price=take_profit_price, ) position_info['tradeId'] = trade_id except Exception as db_e: logger.debug(f"{symbol} 补建 DB 记录失败(不影响监控): {db_e}") self.active_positions[symbol] = position_info logger.info(f"[账号{self.account_id}] {symbol} 已创建持仓记录用于监控" + (" (已写入 DB)" if position_info.get("tradeId") else "")) # 也为“现有持仓”补挂交易所保护单(重启/掉线更安全) 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}") # 重启后立即按当前价做一次保本/移动止损检查并同步,不依赖首条 WS 推送 try: _px = float(position.get("markPrice", 0) or 0) or float(current_price or entry_price) await self._check_single_position(symbol, _px) except Exception as e: logger.debug(f"{symbol} 启动时保本/移动止损检查失败: {e}") except Exception as e: err_msg = str(e) or repr(e) or type(e).__name__ logger.error( f"[账号{self.account_id}] {symbol} 创建临时持仓记录失败: {err_msg}", exc_info=True, ) else: # 已在 active_positions 的持仓:启动前统一补挂/修正交易所 SL/TP(识别缺止盈、止损过远等异常并替换) position_info = self.active_positions.get(symbol) if position_info and position_info.get("stopLoss") and position_info.get("takeProfit"): 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) except Exception as e: logger.warning(f"{symbol} 补挂/修正交易所止损止盈失败(不影响监控): {e}") await self._start_position_monitoring(symbol) logger.info(f"[账号{self.account_id}] 已启动 {len(self._monitor_tasks)} 个持仓的实时监控") async def stop_all_position_monitoring(self): """ 停止所有持仓的WebSocket监控 """ symbols = list(self._monitor_tasks.keys()) for symbol in symbols: await self._stop_position_monitoring(symbol) logger.info(f"已停止所有持仓监控 ({len(symbols)} 个)") async def _start_position_monitoring(self, symbol: str): """ 启动单个持仓的WebSocket价格监控 Args: symbol: 交易对 """ if symbol in self._monitor_tasks: logger.debug(f"{symbol} 监控任务已存在,跳过") return # WebSocket 现在直接使用 aiohttp,不需要检查 socket_manager if not self.client: logger.warning(f"{symbol} 客户端未初始化,无法启动监控") return try: task = asyncio.create_task(self._monitor_position_price(symbol)) self._monitor_tasks[symbol] = task logger.info(f"[账号{self.account_id}] ✓ 启动 {symbol} WebSocket实时价格监控") except Exception as e: logger.error(f"启动 {symbol} 监控失败: {e}") async def _stop_position_monitoring(self, symbol: str): """ 停止单个持仓的WebSocket监控 Args: symbol: 交易对 """ # 幂等:可能会被多处/并发调用,先 pop 再处理,避免 KeyError task = self._monitor_tasks.pop(symbol, None) if task is None: return if not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass logger.debug(f"已停止 {symbol} 的WebSocket监控") async def _monitor_position_price(self, symbol: str): """ 监控单个持仓的价格(WebSocket实时推送) Args: symbol: 交易对 """ retry_count = 0 max_retries = 5 while retry_count < max_retries: try: if symbol not in self.active_positions: logger.info(f"[账号{self.account_id}] {symbol} 持仓已不存在,停止监控") break # 使用WebSocket订阅价格流 # 直接使用 aiohttp 连接 Binance 期货 WebSocket API # 根据文档:https://developers.binance.com/docs/derivatives/usds-margined-futures/websocket-market-streams # 端点:wss://fstream.binance.com/ws/@ticker ws_url = f"wss://fstream.binance.com/ws/{symbol.lower()}@ticker" async with aiohttp.ClientSession() as session: async with session.ws_connect(ws_url) as ws: logger.debug(f"{symbol} WebSocket连接已建立,开始接收价格更新") retry_count = 0 # 连接成功,重置重试计数 async for msg in ws: if symbol not in self.active_positions: logger.info(f"[账号{self.account_id}] {symbol} 持仓已不存在,停止监控") break if msg.type == aiohttp.WSMsgType.TEXT: try: # 解析 JSON 消息 data = json.loads(msg.data) # WebSocket 返回的数据格式:{'e': '24hrTicker', 's': 'BTCUSDT', 'c': '50000.00', ...} # 根据文档,ticker 流包含 'c' 字段(最后价格) if isinstance(data, dict): if 'c' in data: # 'c' 是当前价格 current_price = float(data['c']) # 立即检查止损止盈 await self._check_single_position(symbol, current_price) elif 'data' in data: # 兼容组合流格式(如果使用 /stream 端点) if isinstance(data['data'], dict) and 'c' in data['data']: current_price = float(data['data']['c']) await self._check_single_position(symbol, current_price) except (KeyError, ValueError, TypeError, json.JSONDecodeError) as e: logger.debug(f"{symbol} 解析价格数据失败: {e}, 消息: {msg.data[:100] if hasattr(msg, 'data') else 'N/A'}") continue elif msg.type == aiohttp.WSMsgType.ERROR: logger.warning(f"{symbol} WebSocket错误: {ws.exception()}") break elif msg.type == aiohttp.WSMsgType.CLOSE: logger.info(f"{symbol} WebSocket连接关闭") break except asyncio.CancelledError: logger.info(f"{symbol} 监控任务已取消") break except Exception as e: retry_count += 1 logger.warning(f"{symbol} WebSocket监控出错 (重试 {retry_count}/{max_retries}): {e}") if retry_count < max_retries: # 指数退避重试 wait_time = min(2 ** retry_count, 30) logger.info(f"{symbol} {wait_time}秒后重试连接...") await asyncio.sleep(wait_time) else: logger.error(f"{symbol} WebSocket监控失败,已达到最大重试次数") # 回退到定时检查模式 logger.info(f"{symbol} 将使用定时检查模式(非实时)") break # 清理任务 if symbol in self._monitor_tasks: del self._monitor_tasks[symbol] async def _check_single_position(self, symbol: str, current_price: float): """ 检查单个持仓的止损止盈(实时检查) Args: symbol: 交易对 current_price: 当前价格 """ position_info = self.active_positions.get(symbol) if not position_info: return # 确保所有值都是float类型 entry_price = float(position_info['entryPrice']) quantity = float(position_info['quantity']) current_price_float = float(current_price) # 获取杠杆(确保为float) try: leverage = float(position_info.get('leverage', 10)) except (ValueError, TypeError): leverage = 10.0 # 计算持仓价值和保证金 position_value = entry_price * quantity margin = position_value / leverage if leverage > 0 else position_value # 计算盈亏金额 if position_info['side'] == 'BUY': pnl_amount = (current_price_float - entry_price) * quantity else: # SELL pnl_amount = (entry_price - current_price_float) * quantity # 计算盈亏百分比(ROE - Return on Equity,即相对于保证金的盈亏) # 这是用户最关心的“盈亏比” pnl_percent_margin = (pnl_amount / margin * 100) if margin > 0 else 0 # 也计算价格百分比(不带杠杆的原始涨跌幅) if position_info['side'] == 'BUY': pnl_percent_price = ((current_price_float - entry_price) / entry_price) * 100 else: # SELL pnl_percent_price = ((entry_price - current_price_float) / entry_price) * 100 # 更新最大盈利(基于保证金)及最后一次创新高时间(用于滞涨早止盈) if pnl_percent_margin > position_info.get('maxProfit', 0): position_info['maxProfit'] = pnl_percent_margin position_info['lastNewHighTs'] = time.time() # 滞涨早止盈(实时监控):曾涨到约 N% 后 X 小时内未创新高 → 分批减仓 + 抬止损 stagnation_enabled = bool(config.TRADING_CONFIG.get('STAGNATION_EARLY_EXIT_ENABLED', False)) if stagnation_enabled and not position_info.get('stagnationExitTriggered', False): max_profit = float(position_info.get('maxProfit', 0) or 0) min_runup = float(config.TRADING_CONFIG.get('STAGNATION_MIN_RUNUP_PCT', 10) or 10) stall_hours = float(config.TRADING_CONFIG.get('STAGNATION_NO_NEW_HIGH_HOURS', 3) or 3) partial_pct = float(config.TRADING_CONFIG.get('STAGNATION_PARTIAL_CLOSE_PCT', 0.5) or 0.5) lock_pct = float(config.TRADING_CONFIG.get('STAGNATION_LOCK_PCT', 5) or 5) last_high_ts = position_info.get('lastNewHighTs') if last_high_ts is not None and max_profit >= min_runup and pnl_percent_margin < max_profit: stall_sec = stall_hours * 3600 if (time.time() - last_high_ts) >= stall_sec: logger.info( f"[账号{self.account_id}] {symbol} [滞涨早止盈-实时] 曾达浮盈{max_profit:.2f}%≥{min_runup}%," f"已{stall_hours:.1f}h未创新高,执行分批减仓+抬止损" ) try: close_side = 'SELL' if position_info['side'] == 'BUY' else 'BUY' close_position_side = 'LONG' if position_info['side'] == 'BUY' else 'SHORT' remaining_qty = float(position_info.get('remainingQuantity', quantity)) partial_quantity = remaining_qty * partial_pct live_amt = await self._get_live_position_amt(symbol, position_side=close_position_side) if live_amt is not None and abs(live_amt) > 0: partial_quantity = min(partial_quantity, abs(live_amt)) partial_quantity = await self._adjust_close_quantity(symbol, partial_quantity) if partial_quantity > 0: partial_order = await self.client.place_order( symbol=symbol, side=close_side, quantity=partial_quantity, order_type='MARKET', reduce_only=True, position_side=close_position_side, ) if partial_order: position_info['partialProfitTaken'] = True position_info['remainingQuantity'] = remaining_qty - partial_quantity logger.info(f"[账号{self.account_id}] {symbol} [滞涨早止盈-实时] 部分平仓{partial_quantity:.4f},剩余{position_info['remainingQuantity']:.4f}") remaining_after = float(position_info.get('remainingQuantity', quantity)) lev = float(position_info.get('leverage', 10) or 10) rem_margin = (entry_price * remaining_after) / lev if lev > 0 else (entry_price * remaining_after) lock_pct_use = max(lock_pct, max_profit / 2.0) new_sl = self._stop_price_to_lock_pct(entry_price, position_info['side'], rem_margin, remaining_after, lock_pct_use) breakeven = self._breakeven_stop_price(entry_price, position_info['side']) current_sl = position_info.get('stopLoss') side_here = position_info['side'] set_sl = (side_here == 'BUY' and (current_sl is None or new_sl > current_sl) and new_sl >= breakeven) or ( side_here == 'SELL' and (current_sl is None or new_sl < current_sl) and new_sl <= breakeven) if set_sl: position_info['stopLoss'] = new_sl logger.info(f"[账号{self.account_id}] {symbol} [滞涨早止盈-实时] 剩余仓位止损上移至 {new_sl:.4f},锁定约{lock_pct_use:.1f}%利润") try: await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price_float) except Exception as sync_e: logger.warning(f"[账号{self.account_id}] {symbol} [滞涨早止盈-实时] 同步止损至交易所失败: {sync_e}") position_info['stagnationExitTriggered'] = True except Exception as e: logger.warning(f"[账号{self.account_id}] {symbol} [滞涨早止盈-实时] 执行异常: {e}", exc_info=False) # ⚠️ 2026-01-27修复:提前初始化partial_profit_taken,避免在止损检查时未定义 partial_profit_taken = position_info.get('partialProfitTaken', False) remaining_quantity = position_info.get('remainingQuantity', quantity) # 移动止损逻辑(盈利后保护利润,基于保证金) # 每次检查时从Redis重新加载配置,确保配置修改能即时生效 try: if config._config_manager: config._config_manager.reload_from_redis() config.TRADING_CONFIG = config._get_trading_config() except Exception as e: logger.debug(f"从Redis重新加载配置失败: {e}") # 时间/无盈利止损:持仓超过 N 小时且盈利未达阈值则平仓(低波动期减少磨单) time_stop_enabled = config.TRADING_CONFIG.get('TIME_STOP_ENABLED', False) if time_stop_enabled: max_hold_hours = float(config.get_effective_config('TIME_STOP_MAX_HOLD_HOURS', 8) or 8) min_pnl_to_hold = float(config.TRADING_CONFIG.get('TIME_STOP_MIN_PNL_PCT_TO_HOLD', 0.0) or 0.0) entry_time = position_info.get('entryTime') hold_hours = 0.0 if entry_time: try: if isinstance(entry_time, datetime): hold_hours = (get_beijing_time() - entry_time).total_seconds() / 3600.0 elif isinstance(entry_time, str): entry_dt = datetime.strptime(entry_time, '%Y-%m-%d %H:%M:%S') hold_hours = (get_beijing_time() - entry_dt).total_seconds() / 3600.0 else: hold_hours = (time.time() - (float(entry_time) if isinstance(entry_time, (int, float)) else 0)) / 3600.0 except Exception as e: logger.debug(f"{symbol} 解析 entryTime 失败: {e}") if hold_hours >= max_hold_hours and pnl_percent_margin < min_pnl_to_hold: logger.info( f"{symbol} [时间止损] 持仓 {hold_hours:.1f}h >= {max_hold_hours}h 且盈亏 {pnl_percent_margin:.2f}% < {min_pnl_to_hold}%,平仓" ) if await self.close_position(symbol, reason='time_stop'): logger.info(f"{symbol} [时间止损] 平仓成功") return # ⚠️ 优化:已完全移除时间锁限制 # 理由:1) 止损和止盈都应该立即执行,不受时间限制 # 2) 交易所级别的止损/止盈单已提供保护 # 3) 分步止盈策略本身已提供利润保护 # 4) 及时执行止损/止盈可以保护资金和利润 # 注意:如果需要防止秒级平仓,可以通过提高入场信号质量(MIN_SIGNAL_STRENGTH)来实现 # 检查是否启用移动止损/保本(默认 True,与 config.py 一致;显式设为 False 才关闭) profit_protection_enabled = bool(config.TRADING_CONFIG.get('PROFIT_PROTECTION_ENABLED', True)) use_trailing = profit_protection_enabled and bool(config.TRADING_CONFIG.get('USE_TRAILING_STOP', True)) if use_trailing: logger.debug(f"[账号{self.account_id}] {symbol} [实时监控-移动止损] 已启用,将检查移动止损逻辑") else: if not profit_protection_enabled: logger.debug(f"[账号{self.account_id}] {symbol} [实时监控-移动止损/保本] 已禁用(PROFIT_PROTECTION_ENABLED=False)") else: logger.debug(f"[账号{self.account_id}] {symbol} [实时监控-移动止损] 已禁用(USE_TRAILING_STOP=False),跳过移动止损检查") if use_trailing: trailing_activation = config.TRADING_CONFIG.get('TRAILING_STOP_ACTIVATION', 0.10) # 相对于保证金,默认 10% trailing_protect = config.TRADING_CONFIG.get('TRAILING_STOP_PROTECT', 0.02) # 相对于保证金,默认 2% lock_pct = config.TRADING_CONFIG.get('LOCK_PROFIT_AT_BREAKEVEN_AFTER_PCT') if lock_pct is None: lock_pct = 0.03 # 未配置时默认 3% 移至保本 if lock_pct and lock_pct > 1: lock_pct = lock_pct / 100.0 if not position_info.get('trailingStopActivated', False): # 盈利达一定比例时尽早将止损移至含手续费保本,避免先盈后亏 if lock_pct > 0 and not position_info.get('breakevenStopSet', False) and pnl_percent_margin >= lock_pct * 100: breakeven = self._breakeven_stop_price(entry_price, position_info['side']) current_sl = position_info.get('stopLoss') side_here = position_info['side'] set_breakeven = False if side_here == 'BUY' and (current_sl is None or current_sl < breakeven): set_breakeven = True elif side_here == 'SELL' and (current_sl is None or current_sl > breakeven): set_breakeven = True if set_breakeven: position_info['stopLoss'] = breakeven position_info['breakevenStopSet'] = True logger.info( f"[账号{self.account_id}] {symbol} [实时监控] 盈利{pnl_percent_margin:.2f}%≥{lock_pct*100:.0f}%,止损已移至含手续费保本价 {breakeven:.4f}(留住盈利)" ) _log_trailing_stop_event(self.account_id, symbol, "breakeven_set", breakeven=breakeven, pnl_pct=pnl_percent_margin, source="实时监控") logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 尝试将保本止损同步至交易所") try: await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price_float) _log_trailing_stop_event(self.account_id, symbol, "breakeven_sync_ok", breakeven=breakeven, source="实时监控") except Exception as sync_e: _log_trailing_stop_event(self.account_id, symbol, "breakeven_sync_fail", breakeven=breakeven, error=str(sync_e), source="实时监控") logger.warning( f"[账号{self.account_id}] {symbol} [实时监控] 同步保本止损至交易所失败: {type(sync_e).__name__}: {sync_e}", exc_info=False, ) # 盈利超过阈值后(相对于保证金),激活移动止损 if pnl_percent_margin > trailing_activation * 100: position_info['trailingStopActivated'] = True # 保护利润金额至少覆盖双向手续费,避免“保护”后仍为负 protect_amount = max(margin * trailing_protect, self._min_protect_amount_for_fees(margin, leverage)) if position_info['side'] == 'BUY': new_stop_loss = entry_price + (pnl_amount - protect_amount) / quantity breakeven = self._breakeven_stop_price(entry_price, 'BUY') new_stop_loss = max(new_stop_loss, breakeven) else: # SELL 做空锁利:止损下移,锁住 protect_amount。(entry - stop)*q = protect → stop = entry - protect/q new_stop_loss = entry_price - protect_amount / quantity breakeven = self._breakeven_stop_price(entry_price, 'SELL') new_stop_loss = max(new_stop_loss, breakeven) position_info['stopLoss'] = new_stop_loss logger.info( f"[账号{self.account_id}] {symbol} [实时监控] 移动止损激活: 止损移至保护利润位 {new_stop_loss:.4f} " f"(盈利: {pnl_percent_margin:.2f}% of margin, 保护: {trailing_protect*100:.1f}% of margin)" ) _log_trailing_stop_event(self.account_id, symbol, "trailing_activated", new_stop_loss=new_stop_loss, pnl_pct=pnl_percent_margin, source="实时监控") # 同步至交易所:取消原止损单并按新止损价重挂,使移动止损也有交易所保护 logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 尝试将移动止损同步至交易所") try: await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price_float) _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=new_stop_loss, source="实时监控") logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 已同步移动止损至交易所") except Exception as sync_e: _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=new_stop_loss, error=str(sync_e), source="实时监控") logger.warning( f"[账号{self.account_id}] {symbol} [实时监控] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}", exc_info=False, ) else: # ⚠️ 优化:如果分步止盈第一目标已触发,移动止损不再更新剩余仓位的止损价 # 原因:分步止盈第一目标触发后,剩余50%仓位止损已移至成本价(保本),等待第二目标 # 移动止损不应该覆盖分步止盈设置的止损价 if position_info.get('partialProfitTaken', False): # 分步止盈第一目标已触发,移动止损不再更新 logger.debug(f"[账号{self.account_id}] {symbol} [实时监控-移动止损] 分步止盈第一目标已触发,移动止损不再更新剩余仓位止损价") else: # 盈利超过阈值后,止损移至保护利润位(基于保证金);保护金额至少覆盖手续费 protect_amount = max(margin * trailing_protect, self._min_protect_amount_for_fees(margin, leverage)) if position_info['side'] == 'BUY': new_stop_loss = entry_price + (pnl_amount - protect_amount) / quantity new_stop_loss = max(new_stop_loss, self._breakeven_stop_price(entry_price, 'BUY')) current_sl_buy = position_info.get('stopLoss') if current_sl_buy is None or new_stop_loss > current_sl_buy: position_info['stopLoss'] = new_stop_loss logger.info( f"[账号{self.account_id}] {symbol} [实时监控] 移动止损更新: {new_stop_loss:.4f} " f"(保护{trailing_protect*100:.1f}% of margin = {protect_amount:.4f} USDT)" ) _log_trailing_stop_event(self.account_id, symbol, "trailing_update", new_stop_loss=new_stop_loss, source="实时监控") try: await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price_float) _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=new_stop_loss, source="实时监控") logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 已同步移动止损至交易所") except Exception as sync_e: _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=new_stop_loss, error=str(sync_e), source="实时监控") logger.warning( f"[账号{self.account_id}] {symbol} [实时监控] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}", exc_info=False, ) else: # SELL 做空锁利:止损下移,锁住 protect_amount。(entry - stop)*q = protect → stop = entry - protect/q new_stop_loss = entry_price - protect_amount / quantity new_stop_loss = max(new_stop_loss, self._breakeven_stop_price(entry_price, 'SELL')) current_sl = position_info.get('stopLoss') # 仅当新止损低于当前止损(下移锁利)且高于市价时更新 if (current_sl is None or new_stop_loss < current_sl) and new_stop_loss > current_price_float and pnl_amount > 0: position_info['stopLoss'] = new_stop_loss logger.info( f"[账号{self.account_id}] {symbol} [实时监控] 移动止损更新(做空锁利): {new_stop_loss:.4f} " f"(保护{trailing_protect*100:.1f}% of margin = {protect_amount:.4f} USDT)" ) _log_trailing_stop_event(self.account_id, symbol, "trailing_update", new_stop_loss=new_stop_loss, source="实时监控") try: await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price_float) _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=new_stop_loss, source="实时监控") logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 已同步移动止损至交易所") except Exception as sync_e: _log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=new_stop_loss, error=str(sync_e), source="实时监控") logger.warning( f"[账号{self.account_id}] {symbol} [实时监控] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}", exc_info=False, ) # 检查止损(基于保证金收益比) # ⚠️ 重要:止损检查应该在时间锁之前,止损必须立即执行 stop_loss = position_info.get('stopLoss') should_close_due_to_sl = False exit_reason_sl = None if stop_loss is not None: # 计算止损对应的保证金百分比目标 # 止损金额 = (开仓价 - 止损价) × 数量 或 (止损价 - 开仓价) × 数量 if position_info['side'] == 'BUY': stop_loss_amount = (entry_price - stop_loss) * quantity else: # SELL stop_loss_amount = (stop_loss - entry_price) * quantity stop_loss_pct_margin = (stop_loss_amount / margin * 100) if margin > 0 else 0 # 每5%亏损记录一次诊断日志(帮助排查问题) if pnl_percent_margin <= -5.0: should_log = (int(abs(pnl_percent_margin)) % 5 == 0) or (pnl_percent_margin <= -10.0 and pnl_percent_margin > -10.5) if should_log: trigger_condition = pnl_percent_margin <= -stop_loss_pct_margin # 计算当前价格相对于入场价的变动百分比(不带杠杆) price_change_pct = 0.0 if entry_price > 0: if position_info['side'] == 'BUY': price_change_pct = (current_price_float - entry_price) / entry_price * 100 else: price_change_pct = (entry_price - current_price_float) / entry_price * 100 logger.warning( f"[账号{self.account_id}] {symbol} [实时监控] 诊断: \n" f" • ROE(保证金盈亏): {pnl_percent_margin:.2f}% (用户关注)\n" f" • 价格变动: {price_change_pct:.2f}% (实际币价涨跌)\n" f" • 杠杆倍数: {leverage}x (放大倍数)\n" f" • 当前价: {current_price_float:.4f} | 入场价: {entry_price:.4f}\n" f" • 止损价: {stop_loss:.4f} (目标ROE: -{stop_loss_pct_margin:.2f}%)\n" f" • 触发止损: {'YES' if trigger_condition else 'NO'}" ) # ⚠️ 2026-01-27关键修复:止损检查前,先检查是否盈利 # 如果盈利,不应该触发止损(除非是移动止损或分步止盈后的剩余仓位) # 直接比较当前盈亏百分比与止损目标(基于保证金) if pnl_percent_margin <= -stop_loss_pct_margin: # ⚠️ 额外检查:如果盈利,可能是移动止损触发,应该标记为trailing_stop if pnl_percent_margin > 0: # 盈利单触发止损,应该是移动止损 if position_info.get('trailingStopActivated'): exit_reason_sl = 'trailing_stop' logger.warning(f"[账号{self.account_id}] {symbol} [实时监控] ⚠️ 盈利单触发止损,标记为移动止损(盈利: {pnl_percent_margin:.2f}% of margin)") else: # 盈利单但未激活移动止损,可能是分步止盈后的剩余仓位止损 if partial_profit_taken: exit_reason_sl = 'take_profit_partial_then_stop' logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 第一目标止盈后,剩余仓位触发止损(保本)") else: # 异常情况:盈利单触发止损但未激活移动止损 exit_reason_sl = 'trailing_stop' # 默认标记为移动止损 logger.warning(f"[账号{self.account_id}] {symbol} [实时监控] ⚠️ 异常:盈利单触发止损但未激活移动止损,标记为移动止损") else: # 正常止损逻辑 should_close_due_to_sl = True # ⚠️ 2026-01-27优化:如果已部分止盈,细分状态 if partial_profit_taken: if position_info.get('trailingStopActivated'): exit_reason_sl = 'take_profit_partial_then_trailing_stop' else: exit_reason_sl = 'take_profit_partial_then_stop' else: exit_reason_sl = 'trailing_stop' if position_info.get('trailingStopActivated') else 'stop_loss' # 计算持仓时间 entry_time = position_info.get('entryTime') hold_time_minutes = 0 if entry_time: try: if isinstance(entry_time, datetime): hold_time_sec = int((get_beijing_time() - entry_time).total_seconds()) else: hold_time_sec = int(time.time() - (float(entry_time) if isinstance(entry_time, (int, float)) else 0)) hold_time_minutes = hold_time_sec / 60.0 except Exception: hold_time_minutes = 0 # 详细诊断日志:记录平仓时的所有关键信息 logger.warning("=" * 80) logger.warning(f"[账号{self.account_id}] {symbol} [实时监控-平仓诊断日志] ===== 触发止损平仓 =====") logger.warning(f" 平仓原因: {exit_reason_sl}") logger.warning(f" 入场价格: {entry_price:.6f} USDT") logger.warning(f" 当前价格: {current_price_float:.6f} USDT") logger.warning(f" 止损价格: {stop_loss:.4f} USDT") logger.warning(f" 持仓数量: {quantity:.4f}") logger.warning(f" 持仓时间: {hold_time_minutes:.1f} 分钟") logger.warning(f" 入场时间: {entry_time}") logger.warning(f" 当前盈亏: {pnl_percent_margin:.2f}% of margin") logger.warning(f" 止损目标: -{stop_loss_pct_margin:.2f}% of margin") logger.warning(f" 亏损金额: {abs(pnl_amount):.4f} USDT") if position_info.get('trailingStopActivated'): logger.warning(f" 移动止损: 已激活(从初始止损 {position_info.get('initialStopLoss', 'N/A')} 调整)") logger.warning("=" * 80) # ⚠️ 2026-01-27优化:如果已部分止盈,细分状态为"第一目标止盈后剩余仓位止损" if partial_profit_taken: exit_reason_sl = 'take_profit_partial_then_stop' logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 第一目标止盈后,剩余仓位触发止损(保本)") # ⚠️ 关键修复:止损必须立即执行,不受时间锁限制 if await self.close_position(symbol, reason=exit_reason_sl): logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 止损平仓成功(不受时间锁限制)") return # 止损已执行,跳过后续止盈检查 # 检查分步止盈(实时监控) # ⚠️ 优化:已移除止盈时间锁,止盈可以立即执行(与止损一致) # 理由:1) 止损已不受时间锁限制,止盈也应该一致 # 2) 分步止盈策略本身已提供利润保护(50%在1:1止盈,剩余保本) # 3) 交易所级别止盈单已提供保护 # 4) 及时止盈可以保护利润,避免价格回落 should_close = False take_profit_1 = position_info.get('takeProfit1') # 第一目标(盈亏比1:1) take_profit_2 = position_info.get('takeProfit2', position_info.get('takeProfit')) # 第二目标(1.5:1) # ⚠️ 注意:partial_profit_taken和remaining_quantity已在方法开头初始化,这里不需要重复定义 # 第一目标:TAKE_PROFIT_1_PERCENT 止盈(默认15%保证金),了结50%仓位 if not partial_profit_taken and take_profit_1 is not None: # 直接使用配置的 TAKE_PROFIT_1_PERCENT,与开仓时计算的第一目标一致 take_profit_1_pct_margin_config = config.TRADING_CONFIG.get('TAKE_PROFIT_1_PERCENT', 0.20) # 兼容百分比形式和比例形式 if take_profit_1_pct_margin_config > 1: take_profit_1_pct_margin_config = take_profit_1_pct_margin_config / 100.0 take_profit_1_pct_margin = take_profit_1_pct_margin_config * 100 # 转换为百分比 # 直接比较当前盈亏百分比与第一目标(基于保证金,使用配置值) # ⚠️ 2026-02-04 修复:增加最小盈利检查,防止因配置过低或滑点导致负盈利 # 手续费估算:0.05% * 2 = 0.1% 价格变动。10x杠杆下约1%保证金。保留2%作为安全边际。 min_profit_margin = 2.0 if pnl_percent_margin >= take_profit_1_pct_margin and pnl_percent_margin > min_profit_margin: take_profit_pct_config = config.TRADING_CONFIG.get('TAKE_PROFIT_1_PERCENT', 0.20) if take_profit_pct_config > 1: take_profit_pct_config = take_profit_pct_config / 100.0 take_profit_pct_display = take_profit_pct_config * 100 logger.info( f"[账号{self.account_id}] {symbol} [实时监控] 触发第一目标止盈({take_profit_pct_display:.1f}%固定止盈,基于保证金): " f"当前盈亏={pnl_percent_margin:.2f}% of margin >= 目标={take_profit_1_pct_margin:.2f}% of margin | " f"当前价={current_price_float:.4f}, 目标价={take_profit_1:.4f} | " f"将平掉50%仓位,锁定{take_profit_pct_display:.1f}%盈利,剩余50%追求更高收益" ) # 部分平仓50% partial_quantity = quantity * 0.5 try: close_side = 'SELL' if position_info['side'] == 'BUY' else 'BUY' close_position_side = 'LONG' if position_info['side'] == 'BUY' else 'SHORT' live_amt = await self._get_live_position_amt(symbol, position_side=close_position_side) if live_amt is None or abs(live_amt) <= 0: logger.warning(f"[账号{self.account_id}] {symbol} [实时监控] 部分止盈:实时持仓已为0,跳过部分平仓") else: partial_quantity = min(partial_quantity, abs(live_amt)) partial_quantity = await self._adjust_close_quantity(symbol, partial_quantity) if partial_quantity > 0: partial_order = await self.client.place_order( symbol=symbol, side=close_side, quantity=partial_quantity, order_type='MARKET', reduce_only=True, position_side=close_position_side, ) if partial_order: position_info['partialProfitTaken'] = True position_info['remainingQuantity'] = remaining_quantity - partial_quantity logger.info( f"[账号{self.account_id}] {symbol} [实时监控] 部分止盈成功: 平仓{partial_quantity:.4f},剩余{position_info['remainingQuantity']:.4f}" ) # 分步止盈后的"保本"处理:仅在盈利保护总开关开启时移至含手续费保本价 if profit_protection_enabled: breakeven = self._breakeven_stop_price(entry_price, position_info['side']) position_info['stopLoss'] = breakeven logger.info( f"[账号{self.account_id}] {symbol} [实时监控] 部分止盈后:剩余仓位止损移至含手续费保本价 {breakeven:.4f}(入场: {entry_price:.4f})," f"剩余50%仓位追求更高收益(第二目标:4.0:1盈亏比或更高)" ) except Exception as e: logger.error(f"[账号{self.account_id}] {symbol} [实时监控] 部分止盈失败: {e}") # 第二目标:4.0:1止盈,平掉剩余仓位(山寨币策略) if partial_profit_taken and take_profit_2 is not None and not should_close: # 计算第二目标对应的保证金百分比(基于剩余仓位) if position_info['side'] == 'BUY': take_profit_2_amount = (take_profit_2 - entry_price) * remaining_quantity else: # SELL take_profit_2_amount = (entry_price - take_profit_2) * remaining_quantity remaining_margin = (entry_price * remaining_quantity) / leverage if leverage > 0 else (entry_price * remaining_quantity) take_profit_2_pct_margin = (take_profit_2_amount / remaining_margin * 100) if remaining_margin > 0 else 0 # 计算剩余仓位的当前盈亏 if position_info['side'] == 'BUY': remaining_pnl_amount = (current_price_float - entry_price) * remaining_quantity else: remaining_pnl_amount = (entry_price - current_price_float) * remaining_quantity remaining_pnl_pct_margin = (remaining_pnl_amount / remaining_margin * 100) if remaining_margin > 0 else 0 # 直接比较剩余仓位盈亏百分比与第二目标(基于保证金) # ⚠️ 2026-02-04 修复:增加最小盈利检查 if remaining_pnl_pct_margin >= take_profit_2_pct_margin and remaining_pnl_pct_margin > 2.0: should_close = True # ⚠️ 2026-01-27优化:细分状态,区分"第一目标止盈后第二目标止盈" exit_reason = 'take_profit_partial_then_take_profit' logger.info( f"[账号{self.account_id}] {symbol} [实时监控] 触发第二目标止盈(4.0:1,山寨币策略): " f"剩余仓位盈亏={remaining_pnl_pct_margin:.2f}% of margin >= 目标={take_profit_2_pct_margin:.2f}% of margin | " f"当前价={current_price_float:.4f}, 目标价={take_profit_2:.4f}, " f"剩余数量={remaining_quantity:.4f}" ) # 检查止盈(基于保证金收益比)- 用于未启用分步止盈的情况 if not should_close: take_profit = position_info.get('takeProfit') if take_profit is not None: # 计算止盈对应的保证金百分比目标 # ⚠️ 关键修复:直接使用配置的 TAKE_PROFIT_PERCENT,而不是从止盈价格反推 # 因为止盈价格可能使用了ATR(更远),反推会导致阈值过大,难以触发 take_profit_pct_margin_config = config.TRADING_CONFIG.get('TAKE_PROFIT_PERCENT', 0.10) # 兼容百分比形式和比例形式 if take_profit_pct_margin_config > 1: take_profit_pct_margin_config = take_profit_pct_margin_config / 100.0 take_profit_pct_margin = take_profit_pct_margin_config * 100 # 转换为百分比 # 计算止盈金额(用于日志显示,但不用于触发判断) if position_info['side'] == 'BUY': take_profit_amount = (take_profit - entry_price) * quantity else: # SELL take_profit_amount = (entry_price - take_profit) * quantity take_profit_pct_margin_from_price = (take_profit_amount / margin * 100) if margin > 0 else 0 # 每5%盈利记录一次诊断日志(帮助排查问题) if pnl_percent_margin >= 5.0: should_log = (int(pnl_percent_margin) % 5 == 0) or (pnl_percent_margin >= 10.0 and pnl_percent_margin < 10.5) if should_log: trigger_condition = pnl_percent_margin >= take_profit_pct_margin logger.warning( f"[账号{self.account_id}] {symbol} [实时监控] 诊断: 盈利{pnl_percent_margin:.2f}% of margin | " f"当前价: {current_price_float:.4f} | " f"入场价: {entry_price:.4f} | " f"止盈价: {take_profit:.4f} (配置目标: {take_profit_pct_margin:.2f}% of margin, 价格对应: {take_profit_pct_margin_from_price:.2f}%) | " f"方向: {position_info['side']} | " f"是否触发: {trigger_condition} | " f"监控状态: {'运行中' if symbol in self._monitor_tasks else '未启动'}" ) # 直接比较当前盈亏百分比与止盈目标(基于保证金,使用配置值) # ⚠️ 2026-02-04 修复:增加最小盈利检查 if pnl_percent_margin >= take_profit_pct_margin and pnl_percent_margin > 2.0: should_close = True exit_reason = 'take_profit' # 计算持仓时间(用于日志) entry_time = position_info.get('entryTime') hold_time_minutes = 0 if entry_time: try: if isinstance(entry_time, datetime): hold_time_sec = int((get_beijing_time() - entry_time).total_seconds()) else: hold_time_sec = int(time.time() - (float(entry_time) if isinstance(entry_time, (int, float)) else 0)) hold_time_minutes = hold_time_sec / 60.0 except Exception: hold_time_minutes = 0 # 详细诊断日志:记录平仓时的所有关键信息 logger.info("=" * 80) logger.info(f"[账号{self.account_id}] {symbol} [实时监控-平仓诊断日志] ===== 触发止盈平仓 =====") logger.info(f" 平仓原因: {exit_reason}") logger.info(f" 入场价格: {entry_price:.6f} USDT") logger.info(f" 当前价格: {current_price_float:.6f} USDT") logger.info(f" 止盈价格: {take_profit:.4f} USDT") logger.info(f" 持仓数量: {quantity:.4f}") logger.info(f" 持仓时间: {hold_time_minutes:.1f} 分钟") logger.info(f" 入场时间: {entry_time}") logger.info(f" 当前盈亏: {pnl_percent_margin:.2f}% of margin") logger.info(f" 止盈目标: {take_profit_pct_margin:.2f}% of margin") logger.info(f" 盈利金额: {pnl_amount:.4f} USDT") logger.info("=" * 80) # 如果触发止损止盈,执行平仓 if should_close: # 自动平仓限流:避免同一 symbol 短时间内反复触发平仓请求(WebSocket 高频推送下很常见) try: now_ms = int(time.time() * 1000) except Exception: now_ms = None if now_ms is not None: cooldown_sec = int(config.TRADING_CONFIG.get("AUTO_CLOSE_COOLDOWN_SEC", 20) or 0) last_ms = self._last_auto_close_attempt_ms.get(symbol) if last_ms and cooldown_sec > 0 and now_ms - last_ms < cooldown_sec * 1000: # 不重复刷屏:仅 debug logger.debug(f"{symbol} [自动平仓] 冷却中({cooldown_sec}s),跳过重复平仓尝试") return self._last_auto_close_attempt_ms[symbol] = now_ms logger.info( f"{symbol} [自动平仓] 开始执行平仓操作 | " f"原因: {exit_reason} | " f"入场价: {entry_price:.4f} | " f"当前价: {current_price_float:.4f} | " f"盈亏: {pnl_percent_margin:.2f}% of margin ({pnl_amount:.4f} USDT) | " f"数量: {quantity:.4f}" ) # 执行平仓(让 close_position 统一处理数据库更新,避免重复更新和状态不一致) logger.info(f"{symbol} [自动平仓] 正在执行平仓订单...") success = await self.close_position(symbol, reason=exit_reason) if success: logger.info(f"{symbol} [自动平仓] ✓ 平仓成功完成") # 平仓成功后,立即触发一次状态同步,确保数据库状态与币安一致 try: await asyncio.sleep(2) # 等待2秒让币安订单完全成交 await self.sync_positions_with_binance() logger.debug(f"{symbol} [自动平仓] 已触发状态同步") except Exception as sync_error: logger.warning(f"{symbol} [自动平仓] 状态同步失败: {sync_error}") else: # 平仓失败:先二次核对币安是否已无仓位(常见于竞态/网络抖动/幂等场景) live_amt = None try: live_amt = await self._get_live_position_amt(symbol, position_side=None) except Exception: live_amt = None if live_amt is not None and abs(live_amt) <= 0: logger.warning(f"{symbol} [自动平仓] 平仓返回失败,但币安持仓已为0,按已平仓处理(避免误报)") # 尝试同步一次,让DB与界面尽快一致(失败也不刷屏) try: await self.sync_positions_with_binance() except Exception: pass return # 仍有仓位:减少刷屏(按时间窗口合并/限流) should_log = True if now_ms is not None: log_cd = int(config.TRADING_CONFIG.get("AUTO_CLOSE_FAIL_LOG_COOLDOWN_SEC", 600) or 0) last_log = self._last_auto_close_fail_log_ms.get(symbol) if last_log and log_cd > 0 and now_ms - last_log < log_cd * 1000: should_log = False else: self._last_auto_close_fail_log_ms[symbol] = now_ms if should_log: logger.error(f"{symbol} [自动平仓] ❌ 平仓失败(币安持仓仍存在: {live_amt})") else: logger.warning(f"{symbol} [自动平仓] 平仓仍失败(已在短时间内记录,暂不重复输出)") # 即使平仓失败,也尝试同步状态(可能币安已经平仓了) try: await self.sync_positions_with_binance() except Exception as sync_error: logger.warning(f"{symbol} [自动平仓] 状态同步失败: {sync_error}") async def diagnose_position(self, symbol: str): """ 诊断持仓状态(用于排查为什么没有自动平仓) Args: symbol: 交易对 """ try: logger.info(f"{symbol} [诊断] 开始诊断持仓状态...") # 1. 检查是否在active_positions中 if symbol not in self.active_positions: logger.warning(f"{symbol} [诊断] ❌ 不在本地持仓记录中 (active_positions)") logger.warning(f" 可能原因: 手动开仓或系统重启后未同步") logger.warning(f" 解决方案: 等待下次状态同步或手动触发同步") else: position_info = self.active_positions[symbol] logger.info(f"{symbol} [诊断] ✓ 在本地持仓记录中") logger.info(f" 入场价: {position_info['entryPrice']:.4f}") logger.info(f" 方向: {position_info['side']}") logger.info(f" 数量: {position_info['quantity']:.4f}") logger.info(f" 止损价: {position_info['stopLoss']:.4f}") logger.info(f" 止盈价: {position_info['takeProfit']:.4f}") # 2. 检查WebSocket监控状态 if symbol in self._monitor_tasks: task = self._monitor_tasks[symbol] if task.done(): logger.warning(f"{symbol} [诊断] ⚠ WebSocket监控任务已结束") try: await task # 获取异常信息 except Exception as e: logger.warning(f" 任务异常: {e}") else: logger.info(f"{symbol} [诊断] ✓ WebSocket监控任务运行中") else: logger.warning(f"{symbol} [诊断] ❌ 没有WebSocket监控任务") logger.warning(f" 可能原因: 监控未启动或已停止") # 3. 获取币安实际持仓 positions = await self._get_open_positions() binance_position = next((p for p in positions if p['symbol'] == symbol), None) if not binance_position: logger.warning(f"{symbol} [诊断] ❌ 币安账户中没有持仓") return logger.info(f"{symbol} [诊断] ✓ 币安账户中有持仓") entry_price_binance = binance_position.get('entryPrice', 0) mark_price = binance_position.get('markPrice', 0) unrealized_pnl = binance_position.get('unRealizedProfit', 0) logger.info(f" 币安入场价: {entry_price_binance:.4f}") logger.info(f" 标记价格: {mark_price:.4f}") logger.info(f" 未实现盈亏: {unrealized_pnl:.2f} USDT") # 4. 计算实际盈亏 if symbol in self.active_positions: position_info = self.active_positions[symbol] entry_price = float(position_info['entryPrice']) take_profit = float(position_info['takeProfit']) if position_info['side'] == 'BUY': pnl_percent = ((mark_price - entry_price) / entry_price) * 100 take_profit_pct = ((take_profit - entry_price) / entry_price) * 100 should_trigger = mark_price >= take_profit else: # SELL pnl_percent = ((entry_price - mark_price) / entry_price) * 100 take_profit_pct = ((entry_price - take_profit) / entry_price) * 100 should_trigger = mark_price <= take_profit logger.info(f"{symbol} [诊断] 盈亏分析:") logger.info(f" 当前盈亏: {pnl_percent:.2f}%") logger.info(f" 止盈目标: {take_profit_pct:.2f}%") logger.info(f" 当前价: {mark_price:.4f}") logger.info(f" 止盈价: {take_profit:.4f}") logger.info(f" 价格差: {abs(mark_price - take_profit):.4f}") logger.info(f" 应该触发: {should_trigger}") if pnl_percent > take_profit_pct and not should_trigger: logger.error(f"{symbol} [诊断] ❌ 异常: 盈亏{pnl_percent:.2f}% > 止盈目标{take_profit_pct:.2f}%,但未触发平仓!") logger.error(f" 可能原因: 浮点数精度问题或止盈价格计算错误") logger.info(f"{symbol} [诊断] 诊断完成") except Exception as e: logger.error(f"{symbol} [诊断] 诊断失败: {e}") import traceback logger.error(f" 错误详情:\n{traceback.format_exc()}")