diff --git a/trading_system/STOP_LOSS_AND_TRAILING_ANALYSIS.md b/trading_system/STOP_LOSS_AND_TRAILING_ANALYSIS.md new file mode 100644 index 0000000..8d6edd6 --- /dev/null +++ b/trading_system/STOP_LOSS_AND_TRAILING_ANALYSIS.md @@ -0,0 +1,120 @@ +# 止损与移动止损影响点分析 + +## 一、止损/移动止损涉及的所有环节 + +### 1. 开仓时(初始止损) + +| 位置 | 作用 | 可能影响 | +|------|------|----------| +| `position_manager.open_position` 内 | 调用 `risk_manager.get_stop_loss_price()` 得到初始止损价,写入 `position_info['stopLoss']`、`initialStopLoss` | 初始止损来自 ATR 或 STOP_LOSS_PERCENT(保证金比例) | +| 开仓后 | `_ensure_exchange_sltp_orders(symbol, position_info)` 把 SL/TP 挂到币安 | 条件单显示的是此时传入的 stopLoss/takeProfit | +| `config.TRADING_CONFIG` | `STOP_LOSS_PERCENT`、`USE_ATR_STOP_LOSS`、`ATR_STOP_LOSS_MULTIPLIER` 等 | Redis/配置若把止损压得很紧,容易被打掉 | + +- **结论**:开仓时只设「初始止损」并同步到交易所,是符合设计的;盈利后应由「监控」把止损上移到保本/移动止损。 + +--- + +### 2. 策略与配置(是否做移动止损) + +| 配置项 | 默认 | 说明 | +|--------|------|------| +| `PROFIT_PROTECTION_ENABLED` | True | 关闭后不做保本/移动止损 | +| `USE_TRAILING_STOP` | True | 关闭后不做移动止损(保本仍可做,若上面为 True) | +| `LOCK_PROFIT_AT_BREAKEVEN_AFTER_PCT` | 0.03 | 盈利达保证金 3% 时移至保本(0=关闭) | +| `TRAILING_STOP_ACTIVATION` | 0.10 | 盈利达保证金 10% 后激活移动止损 | +| `TRAILING_STOP_PROTECT` | 0.02 | 移动止损保护利润 2% | +| `FEE_BUFFER_PCT` | 0.0015 | 保本价含手续费缓冲 | + +- **读取方式**:`position_manager._check_single_position` 里用 `config.TRADING_CONFIG.get(...)` 直接读,**没有**走 `get_effective_config`。 +- **结论**:移动止损/保本**不受市场状态(low_volatility/normal)切换影响**;只有 Redis/DB 里把 `PROFIT_PROTECTION_ENABLED` 或 `USE_TRAILING_STOP` 设为 False 时才会不做。 + +--- + +### 3. 市场状态(market regime) + +- `get_effective_config` 只用于:`TIME_STOP_MAX_HOLD_HOURS`、`MAX_POSITION_PERCENT`、`MIN_SIGNAL_STRENGTH`、`SCAN_FUNDING_RATE_MAX_ABS` 等。 +- **结论**:保本/移动止损相关配置**不经过** market regime,市场状态**不会**导致“不能移动止损”。 + +--- + +### 4. 监控与同步到交易所(核心) + +| 环节 | 说明 | 可能导致「条件单仍是初始止损」的原因 | +|------|------|--------------------------------------| +| **谁触发检查** | `_check_single_position(symbol, current_price)` 在两种时机被调用:① WebSocket `@ticker` 收到价格;② 启动时「仅币安有仓」接入后立即调一次。 | 若 WS 断线/未连,只有启动那一次检查;若当时盈利未达 3%,不会移保本,后续又没价格推送,就不会再更新。 | +| **保本条件** | `lock_pct > 0` 且 `not breakevenStopSet` 且 `pnl_percent_margin >= lock_pct*100`(如 3%)→ 设 `position_info['stopLoss'] = breakeven`,`breakevenStopSet = True`,再调 `_ensure_exchange_sltp_orders`。 | 条件不满足(盈利不够、或已标 breakevenStopSet)、或下面同步失败。 | +| **同步到交易所** | `_ensure_exchange_sltp_orders`:先取消旧条件单,再按当前 `position_info['stopLoss']`/`takeProfit2` 挂新单。 | `EXCHANGE_SLTP_ENABLED=False` 会直接 return;或取消/挂单 API 报错(-2021、网络等)被 catch 后只打 log,条件单未更新。 | +| **保本/移动止损价合法性** | 多单 `stop_loss >= entry`、空单 `stop_loss <= entry` 时视为保本/移动止损,**不会**被改成亏损价。 | 无(此处已允许保本/移动止损)。 | + +--- + +### 5. 补建(状态同步)——重要缺口 + +| 场景 | 行为 | 问题 | +|------|------|------| +| **补建**(币安有仓、DB 无 open 记录,且 `sync_recover` 等允许补建) | 用 `risk_manager.get_stop_loss_price()` 算**初始止损**,写入 `position_info`,然后调用 `_ensure_exchange_sltp_orders`。 | **未**先读交易所当前 SL/TP。若交易所上已经是保本或移动止损,会被这次「初始止损」覆盖,条件单又变回亏损价。 | +| **仅币安有仓**(无 DB 记录、走「仅币安有仓」分支) | 先 `_get_sltp_from_exchange(symbol, side)`,有则用交易所 SL/TP 填 `position_info`,缺的才用 risk_manager。 | 逻辑正确,不会覆盖已有保本。 | + +- **结论**:**补建路径**在「建 position_info + 第一次挂 SL/TP」时没有使用交易所现有止损,会**用初始止损覆盖**已在交易所的保本/移动止损,是导致「盈利单条件单仍显示一开始的止损」的一个重要原因。 + +--- + +### 6. 其他 + +- **EXCHANGE_SLTP_ENABLED**:配置为 False 时,`_ensure_exchange_sltp_orders` 不执行,任何保本/移动止损都不会写到交易所。 +- **listen key / User Data Stream**:只影响订单/持仓推送,**不影响**价格流。价格来自 `@ticker` 公开流,不依赖 listen key。但若进程重启或监控未启动,就不会收到价格,也就不会跑 `_check_single_position`。 + +--- + +## 二、根因归纳(为何条件单止损不是正盈利) + +1. **补建时用初始止损覆盖交易所** + 重启或状态同步时,对「缺 DB 记录」的持仓做补建,直接用 risk_manager 的初始止损挂单,没有先读交易所当前 SL;若交易所已是保本/移动止损,会被覆盖回初始止损。 + +2. **监控未及时或未执行** + - WebSocket 断线/重连导致一段时间没有价格推送,`_check_single_position` 不跑。 + - 或启动时只执行了一次检查,当时盈利未达 3%,之后没有新价格触发再次检查。 + +3. **配置关闭了保护** + Redis/DB 中 `PROFIT_PROTECTION_ENABLED` 或 `USE_TRAILING_STOP` 为 False,则不会做保本/移动止损。 + +4. **同步到交易所失败** + `_ensure_exchange_sltp_orders` 内取消/挂单失败(如 -2021、网络异常),只打 warning,条件单未更新。 + +--- + +## 三、补建路径 SL/TP 执行顺序(必须严格保证) + +两处补建(系统单补建、手动开仓补建)都按同一逻辑顺序执行,保证「不先用初始止损覆盖交易所已有保本/移动止损」: + +| 步骤 | 动作 | 说明 | +|------|------|------| +| 1 | **先读交易所** | `_get_sltp_from_exchange(symbol, side)` 得到当前条件单上的 `sl_from_ex`、`tp_from_ex`。 | +| 2 | **算保本价** | `breakeven = _breakeven_stop_price(entry_price, side)`,用于判断交易所止损是否已达保本或更优。 | +| 3 | **决定止损** | 若 `sl_from_ex` 存在且多单 `sl_from_ex >= breakeven` / 空单 `sl_from_ex <= breakeven` → 采用交易所止损、`breakevenStopSet=True`;否则用 `risk_manager.get_stop_loss_price(...)` 作为初始止损。`initialStopLoss` 始终记录 risk_manager 的初始值(用于展示/统计)。 | +| 4 | **决定止盈** | 若 `tp_from_ex` 存在则用交易所止盈,否则用 `risk_manager.get_take_profit_price(...)`。 | +| 5 | **组装 position_info** | 用上面决定好的 `stop_loss_price`、`take_profit_price`、`initial_stop_loss`、`breakevenStopSet` 写入 `position_info`。 | +| 6 | **写入内存** | `self.active_positions[symbol] = position_info`。 | +| 7 | **同步到交易所(仅系统单补建)** | 系统单补建会调用 `_ensure_exchange_sltp_orders(symbol, position_info, current_price)`,用当前 `position_info` 的 SL/TP 挂单,因此传入的必须是步骤 3/4 决定好的价格,这样不会把已有保本覆盖成初始止损。手动开仓补建不在此处调 `_ensure_exchange_sltp_orders`,仅靠 position_info 正确,后续监控若同步时不会误覆盖。 | + +**错误顺序示例**:若先算 `stop_loss_price = risk_manager.get_stop_loss_price(...)`,再 `position_info = {..., "stopLoss": stop_loss_price}`,再 `_ensure_exchange_sltp_orders(...)`,就会用初始止损覆盖交易所上已有的保本/移动止损。因此必须先读交易所、再决定用哪套价格、最后再挂单或写内存。 + +--- + +## 四、已实现修复 + +1. **补建时优先使用交易所 SL/TP**(`position_manager.py`) + - **系统单补建**(约 3405–3439 行):在计算 `stop_loss_price` 前先 `_get_sltp_from_exchange(symbol, side)`;若 `sl_from_ex` 已达保本或更优(多单 `sl >= breakeven`,空单 `sl <= breakeven`),则用 `sl_from_ex` 作为 `position_info['stopLoss']`,并设 `breakevenStopSet=True`,`initialStopLoss` 仍为 risk_manager 的初始值用于记录;止盈同理,有则用 `tp_from_ex`。 + - **手动开仓补建**(约 3524–3590 行):同样先读交易所 SL/TP,若止损已达保本则采用并设 `breakevenStopSet=True`。 + 这样重启或状态同步触发的补建不会用初始止损覆盖交易所上已有的保本/移动止损。 + +2. **补建 position_info 补全 breakevenStopSet** + 两处补建在构建 `position_info` 时均显式写入 `breakevenStopSet`(及 `trailingStopActivated`),与「仅币安有仓」分支一致。 + +3. **代码内注释** + 系统单补建处(约 3405 行起)与手动开仓补建处(约 3532 行起)已加「执行顺序」注释块,与本节一致。 + +4. **排查与观测** + - 确认 Redis 中 `PROFIT_PROTECTION_ENABLED`、`USE_TRAILING_STOP` 为 True,且 `LOCK_PROFIT_AT_BREAKEVEN_AFTER_PCT` 为 0.03(或期望值)。 + - 看日志是否有「[补建] 使用交易所已有止损(保本/移动)」或「[补建-手动] 使用交易所已有止损…」,以及「同步…失败」类告警。 + - 补建修复后,盈利单条件单在重启/同步后应保持为正盈利(保本或移动止损)。 diff --git a/trading_system/position_manager.py b/trading_system/position_manager.py index b19ac0e..2b79c8e 100644 --- a/trading_system/position_manager.py +++ b/trading_system/position_manager.py @@ -3402,6 +3402,20 @@ class PositionManager: 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 @@ -3410,17 +3424,27 @@ class PositionManager: 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, lev, stop_loss_pct=stop_loss_pct) - take_profit_price = self.risk_manager.get_take_profit_price(entry_price, side, quantity, lev, take_profit_pct=take_profit_pct) + 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": stop_loss_price, + "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: @@ -3505,8 +3529,19 @@ class PositionManager: # 创建本地持仓记录(用于监控) ticker = await self.client.get_ticker_24h(symbol) current_price = ticker['price'] if ticker else entry_price + # ---------- 手动开仓补建 SL/TP 顺序:先读交易所 → 决定 SL/TP → 再写 position_info ---------- + # 本分支不调用 _ensure_exchange_sltp_orders,仅写入 active_positions;后续由监控在价格推送时可能调用。 + # 若此处用 risk_manager 初始止损写 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) # ⚠️ 关键修复:配置值格式转换(兼容百分比形式和比例形式) @@ -3520,14 +3555,26 @@ class PositionManager: if take_profit_pct_margin is None or take_profit_pct_margin == 0: take_profit_pct_margin = stop_loss_pct_margin * 2.0 - stop_loss_price = self.risk_manager.get_stop_loss_price( - entry_price, side, quantity, leverage, - stop_loss_pct=stop_loss_pct_margin - ) - take_profit_price = self.risk_manager.get_take_profit_price( - entry_price, side, quantity, leverage, - take_profit_pct=take_profit_pct_margin - ) + 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, @@ -3539,14 +3586,14 @@ class PositionManager: 'tradeId': trade_id, 'stopLoss': stop_loss_price, 'takeProfit': take_profit_price, - 'initialStopLoss': stop_loss_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': False + 'breakevenStopSet': use_exchange_sl } self.active_positions[symbol] = position_info