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)