From 5d5ead36ac733f2f991af9c2864dcc46cb0ac7f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=87=E8=96=87=E5=AE=89?= Date: Fri, 27 Feb 2026 23:48:05 +0800 Subject: [PATCH] feat(strategy): Implement stagnation early exit strategy configuration and logic Added new configuration options for the stagnation early exit strategy, allowing for partial position closure and stop-loss adjustments when a position has not reached a new high after a specified period. Updated relevant backend logic to handle this strategy, including tracking maximum profit and timestamps for the last new high. Frontend components were also updated to reflect these new settings and their descriptions, enhancing user control over trading strategies. --- backend/api/routes/config.py | 31 ++++++ frontend/src/components/GlobalConfig.jsx | 15 +++ trading_system/config.py | 6 + trading_system/position_manager.py | 133 ++++++++++++++++++++++- 4 files changed, 182 insertions(+), 3 deletions(-) diff --git a/backend/api/routes/config.py b/backend/api/routes/config.py index 687ca8c..7261af8 100644 --- a/backend/api/routes/config.py +++ b/backend/api/routes/config.py @@ -624,6 +624,37 @@ async def get_global_configs( "category": "scan", "description": "固定错峰模式下每个账号之间的步长秒数:延迟 = (account_id-1)*SCAN_STAGGER_SEC。", }, + # ===== 滞涨早止盈(涨到约 N% 后长时间不创新高就分批减仓+抬止损)===== + "STAGNATION_EARLY_EXIT_ENABLED": { + "value": False, + "type": "boolean", + "category": "strategy", + "description": "是否启用「滞涨早止盈」:曾达到一定浮盈后,长时间未创新高则主动分批减仓并抬升止损锁利。", + }, + "STAGNATION_MIN_RUNUP_PCT": { + "value": 10.0, + "type": "number", + "category": "strategy", + "description": "触发滞涨判断的最低历史浮盈百分比(基于保证金),如 10 表示曾浮盈≥10% 才会考虑滞涨早止盈。", + }, + "STAGNATION_NO_NEW_HIGH_HOURS": { + "value": 3.0, + "type": "number", + "category": "strategy", + "description": "在达到最高浮盈后,若连续这么多个小时未再创新高,则视为动能衰减并触发滞涨早止盈。", + }, + "STAGNATION_PARTIAL_CLOSE_PCT": { + "value": 0.5, + "type": "number", + "category": "strategy", + "description": "滞涨触发时首先平掉的仓位比例,如 0.5 表示先平掉 50% 锁定一半利润。", + }, + "STAGNATION_LOCK_PCT": { + "value": 5.0, + "type": "number", + "category": "strategy", + "description": "滞涨触发后对剩余仓位抬升止损时,至少要锁住的利润百分比(与“最高浮盈的一半”取较大者)。", + }, } for k, meta in ADDITIONAL_STRATEGY_DEFAULTS.items(): if k not in result: diff --git a/frontend/src/components/GlobalConfig.jsx b/frontend/src/components/GlobalConfig.jsx index d969ec4..3428b6c 100644 --- a/frontend/src/components/GlobalConfig.jsx +++ b/frontend/src/components/GlobalConfig.jsx @@ -15,6 +15,11 @@ import './ConfigPanel.css' // 复用 ConfigPanel 的样式 'MAX_CHANGE_PERCENT_FOR_SHORT', 'MIN_RR_FOR_TP1', 'POSITION_SCALE_FACTOR', + // 滞涨相关百分比配置使用“数值原样”,10 表示 10% + 'STAGNATION_MIN_RUNUP_PCT', + 'STAGNATION_NO_NEW_HIGH_HOURS', + 'STAGNATION_PARTIAL_CLOSE_PCT', + 'STAGNATION_LOCK_PCT', ]) // 配置项中文标签(便于识别) const KEY_LABELS = { @@ -40,6 +45,11 @@ const KEY_LABELS = { SCAN_STAGGER_MIN_SEC: '错峰最小延迟(秒)', SCAN_STAGGER_MAX_SEC: '错峰最大延迟(秒)', SCAN_STAGGER_SEC: '固定错峰步长(秒)', + STAGNATION_EARLY_EXIT_ENABLED: '滞涨早止盈开关', + STAGNATION_MIN_RUNUP_PCT: '滞涨判定最低历史浮盈(%)', + STAGNATION_NO_NEW_HIGH_HOURS: '滞涨判定未创新高时长(小时)', + STAGNATION_PARTIAL_CLOSE_PCT: '滞涨触发时先平仓位比例', + STAGNATION_LOCK_PCT: '滞涨后抬止损至少锁利(%)', } // 配置项详细说明(用于导出的策略配置分析,含建议与参数说明) @@ -105,6 +115,11 @@ const CONFIG_GUIDE_DETAILS = { SCAN_STAGGER_MIN_SEC: '随机错峰模式下的最小延迟秒数。例如 10 表示至少延迟 10 秒。建议:多账号时可设 10-30。', SCAN_STAGGER_MAX_SEC: '随机错峰模式下的最大延迟秒数。例如 120 表示至多延迟 120 秒。建议:多账号时可设 60-300,根据你能接受的最大启动等待时间调整。', SCAN_STAGGER_SEC: '固定错峰模式下每个账号之间的步长(秒)。实际延迟 = (account_id-1)*SCAN_STAGGER_SEC。例如 account_id=3 且步长 60,则延迟 120 秒后开始扫描。', + STAGNATION_EARLY_EXIT_ENABLED: '滞涨早止盈:当持仓曾达到一定浮盈后,长时间未再创新高(动能衰减)时,系统会主动执行“先部分平仓锁定利润 + 对剩余仓位抬高止损”的组合动作。建议只在回测/实盘验证效果良好后再开启。', + STAGNATION_MIN_RUNUP_PCT: '触发滞涨早止盈前,历史最高浮盈至少要达到的百分比(基于保证金)。例如 10 表示曾浮盈≥10% 才会进入滞涨监控,防止对小级别波动过度敏感。', + STAGNATION_NO_NEW_HIGH_HOURS: '从最近一次创历史新高开始计时,如果连续这么多小时都没有再创新高(价格高点停滞),则视为“滞涨”,会触发滞涨早止盈逻辑。建议 2-4 小时区间,根据周期调整。', + STAGNATION_PARTIAL_CLOSE_PCT: '滞涨触发后,首先立即平掉的仓位比例,用于锁定一部分利润、降低回撤风险。例如 0.5 表示先平掉 50% 仓位,剩余 50% 继续观察。', + STAGNATION_LOCK_PCT: '滞涨触发后,对剩余仓位抬升止损时,至少要锁住的利润百分比(基于保证金)。实际使用时会与“历史最高浮盈的一半”取较大值,用于在强势趋势中多保留利润空间,在一般情况下则锁定一个稳定的收益区间。', } const ConfigItem = ({ label, config, onUpdate, disabled }) => { diff --git a/trading_system/config.py b/trading_system/config.py index 9951f0e..6c2dfe2 100644 --- a/trading_system/config.py +++ b/trading_system/config.py @@ -264,6 +264,12 @@ DEFAULT_TRADING_CONFIG = { 'EARLY_TAKE_PROFIT_ENABLED': True, # 是否启用「盈利达标+持仓时长」早止盈 'EARLY_TAKE_PROFIT_MIN_PNL_PCT': 10.0, # 盈利达到此比例(% of margin)即符合条件,默认 10% 'EARLY_TAKE_PROFIT_MIN_HOLD_HOURS': 2.0, # 且持仓至少 N 小时才触发(避免刚开仓就平) + # 滞涨早止盈:曾涨到约 N% 后 X 小时内未创新高则分批减仓+抬止损(动能衰减) + 'STAGNATION_EARLY_EXIT_ENABLED': False, # 是否启用「滞涨早止盈」(默认关,按需开启) + 'STAGNATION_MIN_RUNUP_PCT': 10.0, # 曾达到的最低浮盈%(基于保证金)才考虑滞涨,例如 10 表示 10% + 'STAGNATION_NO_NEW_HIGH_HOURS': 3.0, # 多少小时内未创新高视为滞涨,例如 2~4 + 'STAGNATION_PARTIAL_CLOSE_PCT': 0.5, # 触发时先平掉仓位比例,例如 0.5=50% + 'STAGNATION_LOCK_PCT': 5.0, # 剩余仓位止损上移锁住的利润%(与「最高浮盈的一半」取大) # 开仓前流动性检查(价差/深度):低量期可减少滑点与冲击成本 'LIQUIDITY_CHECK_BEFORE_ENTRY': False, # 默认关闭;开启后开仓前检查盘口价差与深度,不通过则跳过 'LIQUIDITY_MAX_SPREAD_PCT': 0.005, # 买一卖一价差超过此比例(0.5%)则跳过 diff --git a/trading_system/position_manager.py b/trading_system/position_manager.py index 1a2aa39..bca07ba 100644 --- a/trading_system/position_manager.py +++ b/trading_system/position_manager.py @@ -1016,7 +1016,9 @@ class PositionManager: 'entryTime': get_beijing_time(), # 记录入场时间(使用北京时间,用于计算持仓持续时间) 'strategyType': 'trend_following', # 策略类型(简化后只有趋势跟踪) 'atr': atr, - 'maxProfit': 0.0, # 记录最大盈利(用于移动止损) + 'maxProfit': 0.0, # 记录最大盈利(用于移动止损/滞涨判断) + 'lastNewHighTs': None, # 最后一次创新高的时间戳(秒),用于滞涨早止盈 + 'stagnationExitTriggered': False, # 是否已执行滞涨分批减仓+抬止损(只执行一次) 'trailingStopActivated': False, # 移动止损是否已激活 'breakevenStopSet': False, # 是否已执行“盈利达阈值后移至保本” 'account_id': self.account_id @@ -1475,6 +1477,20 @@ class PositionManager: # 双向约 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)。 @@ -2041,10 +2057,65 @@ class PositionManager: 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重新加载配置,确保配置修改能即时生效 @@ -4179,9 +4250,65 @@ class PositionManager: 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)