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.
This commit is contained in:
薇薇安 2026-02-27 23:48:05 +08:00
parent 849b550910
commit 5d5ead36ac
4 changed files with 182 additions and 3 deletions

View File

@ -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:

View File

@ -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 }) => {

View File

@ -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, # 多少小时内未创新高视为滞涨,例如 24
'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%)则跳过

View File

@ -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)