diff --git a/backend/api/routes/config.py b/backend/api/routes/config.py index 0b2414d..339f35c 100644 --- a/backend/api/routes/config.py +++ b/backend/api/routes/config.py @@ -83,6 +83,10 @@ USER_VISIBLE_DEFAULTS = { "EXCLUDE_MAJOR_COINS": {"value": True, "type": "boolean", "category": "scan", "description": "是否排除主流币(BTC/ETH 等)"}, "MAX_SCAN_SYMBOLS": {"value": 1500, "type": "number", "category": "scan", "description": "最大扫描交易对数量"}, "SCAN_INTERVAL": {"value": 1800, "type": "number", "category": "scan", "description": "市场扫描间隔(秒)"}, + "MANUAL_REDUCED_SYMBOLS": {"value": "", "type": "string", "category": "strategy", "description": "手动减仓交易对列表,逗号/空格/换行分隔。命中后缩小仓位并提高自动交易门槛。"}, + "MANUAL_BLOCKED_SYMBOLS": {"value": "", "type": "string", "category": "strategy", "description": "手动停做交易对列表,逗号/空格/换行分隔。命中后禁止自动开仓。"}, + "MANUAL_REDUCED_SYMBOL_POSITION_FACTOR": {"value": 0.5, "type": "number", "category": "strategy", "description": "手动减仓交易对的仓位系数。0.5 表示只开正常仓位的一半。"}, + "MANUAL_REDUCED_SYMBOL_SIGNAL_BOOST": {"value": 1, "type": "number", "category": "strategy", "description": "手动减仓交易对额外提高的最小信号门槛。1 表示在当前基础上 +1。"}, } RISK_KNOBS_DEFAULTS = { @@ -214,12 +218,30 @@ AUTO_TRADE_FILTER_DEFAULTS = { "category": "strategy", "description": "自动交易仅在市场状态=trending时执行(ranging/unknown只生成推荐,不自动下单)。用于显著降低震荡扫损与交易次数。", }, + "AUTO_TRADE_ALLOW_RANGING": { + "value": False, + "type": "boolean", + "category": "strategy", + "description": "当不强制 only_trending 时,是否允许在震荡市自动交易。默认关闭,避免在横盘里来回扫损。", + }, + "AUTO_TRADE_ALLOW_UNKNOWN": { + "value": False, + "type": "boolean", + "category": "strategy", + "description": "当市场状态=unknown(K线不足或未判定)时,是否允许自动交易。默认关闭,更稳健。", + }, "AUTO_TRADE_ALLOW_4H_NEUTRAL": { "value": False, "type": "boolean", "category": "strategy", "description": "是否允许4H趋势=neutral时自动交易。默认关闭(中性趋势最容易被扫损);若你希望更积极可开启。", }, + "RANGING_MARKET_SIGNAL_BOOST": { + "value": 2, + "type": "number", + "category": "strategy", + "description": "震荡市额外提高的最小信号门槛。比如 2 表示在基础 MIN_SIGNAL_STRENGTH 上再 +2。", + }, } # 风险/策略预设(用于一键切换“稳健 / 快速验证”等模式) @@ -544,6 +566,30 @@ async def get_global_configs( "category": "strategy", "description": "盈利达保证金的该比例时移至保本(如 0.03=3%,0=关闭保本步骤)", }, + "LOCK_PROFIT_STAGE1_TRIGGER_PCT": { + "value": 0.08, + "type": "number", + "category": "strategy", + "description": "分层锁盈第一层触发阈值(相对保证金,如 0.08=8%)。达到后将止损从保本抬到小幅锁利。", + }, + "LOCK_PROFIT_STAGE1_PCT": { + "value": 0.02, + "type": "number", + "category": "strategy", + "description": "分层锁盈第一层锁利比例(相对保证金,如 0.02=2%)。", + }, + "LOCK_PROFIT_STAGE2_TRIGGER_PCT": { + "value": 0.15, + "type": "number", + "category": "strategy", + "description": "分层锁盈第二层触发阈值(相对保证金,如 0.15=15%)。达到后进一步抬高止损。", + }, + "LOCK_PROFIT_STAGE2_PCT": { + "value": 0.05, + "type": "number", + "category": "strategy", + "description": "分层锁盈第二层锁利比例(相对保证金,如 0.05=5%)。", + }, "USE_TRAILING_STOP": { "value": False, "type": "boolean", diff --git a/backend/config_manager.py b/backend/config_manager.py index 98f2073..1e81490 100644 --- a/backend/config_manager.py +++ b/backend/config_manager.py @@ -777,6 +777,10 @@ class ConfigManager: percent_keys = [ 'TRAILING_STOP_ACTIVATION', 'TRAILING_STOP_PROTECT', + 'LOCK_PROFIT_STAGE1_TRIGGER_PCT', + 'LOCK_PROFIT_STAGE1_PCT', + 'LOCK_PROFIT_STAGE2_TRIGGER_PCT', + 'LOCK_PROFIT_STAGE2_PCT', 'MIN_VOLATILITY', 'TAKE_PROFIT_PERCENT', 'TAKE_PROFIT_1_PERCENT', # 分步止盈第一目标(默认15%) @@ -897,6 +901,10 @@ class ConfigManager: # 盈利保护总开关与保本:关闭后不执行保本、不执行移动止损 'PROFIT_PROTECTION_ENABLED': eff_get('PROFIT_PROTECTION_ENABLED', True), # True=启用保本+移动止损,False=全部关闭 'LOCK_PROFIT_AT_BREAKEVEN_AFTER_PCT': eff_get('LOCK_PROFIT_AT_BREAKEVEN_AFTER_PCT', 0.03), # 盈利达保证金比例时移至保本(0.03=3%,0=关闭) + 'LOCK_PROFIT_STAGE1_TRIGGER_PCT': eff_get('LOCK_PROFIT_STAGE1_TRIGGER_PCT', 0.08), # 盈利达该比例后进入第一层锁盈 + 'LOCK_PROFIT_STAGE1_PCT': eff_get('LOCK_PROFIT_STAGE1_PCT', 0.02), # 第一层锁住的利润比例 + 'LOCK_PROFIT_STAGE2_TRIGGER_PCT': eff_get('LOCK_PROFIT_STAGE2_TRIGGER_PCT', 0.15), # 盈利达该比例后进入第二层锁盈 + 'LOCK_PROFIT_STAGE2_PCT': eff_get('LOCK_PROFIT_STAGE2_PCT', 0.05), # 第二层锁住的利润比例 # 移动止损 'USE_TRAILING_STOP': eff_get('USE_TRAILING_STOP', True), # 默认启用(2026-01-27优化:启用移动止损,保护利润) 'TRAILING_STOP_ACTIVATION': eff_get('TRAILING_STOP_ACTIVATION', 0.05), # 默认5%(2026-01-27优化:更早保护利润,避免回吐) @@ -909,7 +917,10 @@ class ConfigManager: # 说明:这两个 key 需要出现在 TRADING_CONFIG 中,否则 trading_system 在每次 reload_from_redis 后会丢失它们, # 导致始终按默认值拦截自动交易(用户在配置页怎么开都没用)。 'AUTO_TRADE_ONLY_TRENDING': eff_get('AUTO_TRADE_ONLY_TRENDING', True), + 'AUTO_TRADE_ALLOW_RANGING': eff_get('AUTO_TRADE_ALLOW_RANGING', False), + 'AUTO_TRADE_ALLOW_UNKNOWN': eff_get('AUTO_TRADE_ALLOW_UNKNOWN', False), 'AUTO_TRADE_ALLOW_4H_NEUTRAL': eff_get('AUTO_TRADE_ALLOW_4H_NEUTRAL', allow_neutral_default), + 'RANGING_MARKET_SIGNAL_BOOST': eff_get('RANGING_MARKET_SIGNAL_BOOST', 2), # 智能入场/限价偏移(部分逻辑会直接读取 TRADING_CONFIG) 'LIMIT_ORDER_OFFSET_PCT': eff_get('LIMIT_ORDER_OFFSET_PCT', 0.5), diff --git a/frontend/src/components/GlobalConfig.jsx b/frontend/src/components/GlobalConfig.jsx index 478dfb9..86cc5ce 100644 --- a/frontend/src/components/GlobalConfig.jsx +++ b/frontend/src/components/GlobalConfig.jsx @@ -35,7 +35,11 @@ const KEY_LABELS = { RSI_EXTREME_REVERSE_ENABLED: 'RSI 极端反向(超买反空/超卖反多)', RSI_EXTREME_REVERSE_ONLY_NEUTRAL_4H: 'RSI 反向仅允许 4H 中性', MIN_RR_FOR_TP1: '第一目标最小盈亏比(相对止损)', + AUTO_TRADE_ONLY_TRENDING: '仅趋势市自动交易', + AUTO_TRADE_ALLOW_RANGING: '允许震荡市自动交易', + AUTO_TRADE_ALLOW_UNKNOWN: '允许未判定市场自动交易', AUTO_TRADE_ALLOW_4H_NEUTRAL: '允许 4H 中性时自动交易', + RANGING_MARKET_SIGNAL_BOOST: '震荡市额外信号门槛', POSITION_SCALE_FACTOR: '仓位放大系数', SYNC_RECOVER_MISSING_POSITIONS: '同步时补建缺失持仓', SYNC_RECOVER_ONLY_WHEN_HAS_SLTP: '仅当有止损/止盈单时补建', @@ -47,6 +51,14 @@ const KEY_LABELS = { NIGHT_HOURS_END: '禁止开仓结束(时)', NIGHT_HOURS_ONLY_SUNDAY: '仅周六21点~周日06点', NO_OPEN_HOURS_BJ: '禁止开仓小时(北京)', + MANUAL_REDUCED_SYMBOLS: '手动减仓交易对', + MANUAL_BLOCKED_SYMBOLS: '手动停做交易对', + MANUAL_REDUCED_SYMBOL_POSITION_FACTOR: '减仓名单仓位系数', + MANUAL_REDUCED_SYMBOL_SIGNAL_BOOST: '减仓名单额外信号门槛', + LOCK_PROFIT_STAGE1_TRIGGER_PCT: '第一层锁盈触发阈值', + LOCK_PROFIT_STAGE1_PCT: '第一层锁盈比例', + LOCK_PROFIT_STAGE2_TRIGGER_PCT: '第二层锁盈触发阈值', + LOCK_PROFIT_STAGE2_PCT: '第二层锁盈比例', SCAN_STAGGER_BY_ACCOUNT: '多账号错峰扫描', SCAN_STAGGER_RANDOM: '错峰随机模式', SCAN_STAGGER_MIN_SEC: '错峰最小延迟(秒)', @@ -83,7 +95,10 @@ const CONFIG_GUIDE_DETAILS = { USE_TRAILING_STOP: '是否启用移动止损。启用后,当盈利达到激活阈值时,止损会自动跟踪价格,保护利润。适合趋势行情。建议:平衡和激进策略启用,保守策略可关闭。', SMART_ENTRY_ENABLED: '智能入场开关。开启时会进行"限价回调 + 追价 +(趋势强时)市价兜底";关闭时回归"纯限价单模式",更适合低频波段与控频。', AUTO_TRADE_ONLY_TRENDING: '自动交易仅在市场状态=trending时执行(ranging/unknown只生成推荐,不自动下单)。能显著降低震荡扫损,但也会减少出单。', + AUTO_TRADE_ALLOW_RANGING: '当不强制 only_trending 时,是否允许在震荡市自动交易。默认关闭,避免横盘来回扫损。', + AUTO_TRADE_ALLOW_UNKNOWN: '当 marketRegime=unknown(K线不足或未判定)时,是否允许自动交易。默认关闭更稳健。', AUTO_TRADE_ALLOW_4H_NEUTRAL: '是否允许4H趋势=neutral时自动交易。关闭可减少震荡扫损;开启会显著增加出单(需配合更严格的信号门槛)。', + RANGING_MARKET_SIGNAL_BOOST: '震荡市额外提高的最小信号门槛。比如 2 表示在基础 MIN_SIGNAL_STRENGTH 上再 +2,只保留更强的震荡市信号。', LIMIT_ORDER_OFFSET_PCT: '限价入场偏移(%)。BUY 挂在当前价下方,SELL 挂在当前价上方。值越小越贴近当前价,更容易成交;值越大更保守。建议:0.05%-0.20%。', ENTRY_CONFIRM_TIMEOUT_SEC: '下单后等待成交的确认超时(秒)。在纯限价模式下,超时未成交会撤单并跳过。建议:60-180秒。', ENTRY_CHASE_MAX_STEPS: '智能入场最大追价步数。步数越多越不容易错过,但也更接近追价;建议 2-4 步。', @@ -111,6 +126,14 @@ const CONFIG_GUIDE_DETAILS = { NIGHT_HOURS_END: '禁止开仓结束小时(北京),不含', NIGHT_HOURS_ONLY_SUNDAY: 'True=仅周六21:00~周日06:00禁止;False=每天21:00~06:00禁止', NO_OPEN_HOURS_BJ: '禁止开仓小时(北京),逗号分隔如 17,19,22 表示该整点不开新仓;空则不限制。按历史亏损时段配置。', + MANUAL_REDUCED_SYMBOLS: '手动减仓交易对列表,支持逗号、空格或换行分隔。命中后仅缩小仓位并提高自动交易门槛,不会完全停做。', + MANUAL_BLOCKED_SYMBOLS: '手动停做交易对列表,支持逗号、空格或换行分隔。命中后直接禁止自动开仓,仅保留平仓与手动干预。', + MANUAL_REDUCED_SYMBOL_POSITION_FACTOR: '手动减仓交易对的仓位系数。比如 0.5 表示此类币只按正常仓位的 50% 下单。', + MANUAL_REDUCED_SYMBOL_SIGNAL_BOOST: '手动减仓交易对额外提高的最小信号门槛。比如 1 表示在原门槛基础上再 +1,用于减少边缘信号开仓。', + LOCK_PROFIT_STAGE1_TRIGGER_PCT: '分层锁盈第一层触发阈值(相对保证金,如 0.08=8%)。达到后会把止损从保本抬到小幅锁利。', + LOCK_PROFIT_STAGE1_PCT: '分层锁盈第一层锁利比例(相对保证金,如 0.02=2%)。', + LOCK_PROFIT_STAGE2_TRIGGER_PCT: '分层锁盈第二层触发阈值(相对保证金,如 0.15=15%)。达到后进一步抬高止损,减少利润回吐。', + LOCK_PROFIT_STAGE2_PCT: '分层锁盈第二层锁利比例(相对保证金,如 0.05=5%)。', BINANCE_API_KEY: '币安API密钥。用于访问币安账户的凭证,需要启用"合约交易"权限。请妥善保管,不要泄露。', BINANCE_API_SECRET: '币安API密钥Secret。与API Key配对使用,用于签名验证。请妥善保管,不要泄露。', USE_TESTNET: '是否使用币安测试网。true表示模拟交易(无真实资金),false表示真实交易。', @@ -585,7 +608,11 @@ const GlobalConfig = () => { SYMBOL_LOSS_COOLDOWN_SEC: { value: 3600, type: 'number', category: 'strategy', description: '连续亏损后的冷却时间(秒),默认1小时。' }, MIN_RR_FOR_TP1: { value: 1.5, type: 'number', category: 'strategy', description: '第一目标止盈相对止损的最小盈亏比(如 1.5 表示 TP1 至少为止损距离的 1.5 倍)。2026-02-12 新增。' }, POSITION_SCALE_FACTOR: { value: 1.0, type: 'number', category: 'risk', description: '仓位放大系数:1.0=正常,1.2=+20% 仓位,1.5=+50%,上限 2.0。盈利时适度调高可扩大收益,仍受单笔上限约束。' }, + AUTO_TRADE_ONLY_TRENDING: { value: true, type: 'boolean', category: 'strategy', description: '仅在趋势市自动交易。' }, + AUTO_TRADE_ALLOW_RANGING: { value: false, type: 'boolean', category: 'strategy', description: '当不强制 only_trending 时,是否允许在震荡市自动交易。' }, + AUTO_TRADE_ALLOW_UNKNOWN: { value: false, type: 'boolean', category: 'strategy', description: '当市场状态未判定时,是否允许自动交易。' }, AUTO_TRADE_ALLOW_4H_NEUTRAL: { value: false, type: 'boolean', category: 'strategy', description: '是否允许 4H 趋势为中性时自动交易。关闭可减少震荡扫损、提升质量(建议关闭)。' }, + RANGING_MARKET_SIGNAL_BOOST: { value: 2, type: 'number', category: 'strategy', description: '震荡市额外提高的最小信号门槛。' }, BETA_FILTER_ENABLED: { value: true, type: 'boolean', category: 'strategy', description: '大盘共振过滤:BTC/ETH 下跌时屏蔽多单。' }, BETA_FILTER_THRESHOLD: { value: -0.005, type: 'number', category: 'strategy', description: '大盘共振阈值(比例,如 -0.005 表示 -0.5%)。' }, RSI_EXTREME_REVERSE_ENABLED: { value: false, type: 'boolean', category: 'strategy', description: '开启后:原信号做多但 RSI 超买(≥做多上限)时改为做空;原信号做空但 RSI 超卖(≤做空下限)时改为做多。属均值回归思路,可填补超买超卖时不下单的空置;默认关闭。' }, @@ -630,6 +657,14 @@ const GlobalConfig = () => { NIGHT_HOURS_END: { value: 6, type: 'number', category: 'risk', description: '禁止开仓结束小时(北京),不含。' }, NIGHT_HOURS_ONLY_SUNDAY: { value: true, type: 'boolean', category: 'risk', description: 'True=仅周六21:00~周日06:00禁止;False=每天21:00~06:00禁止。' }, NO_OPEN_HOURS_BJ: { value: '', type: 'string', category: 'risk', description: '禁止开仓小时(北京),逗号分隔如 17,19,22 表示该整点不开新仓;空则不限制。按历史亏损时段配置。' }, + MANUAL_REDUCED_SYMBOLS: { value: '', type: 'string', category: 'strategy', description: '手动减仓交易对列表,支持逗号、空格或换行分隔。' }, + MANUAL_BLOCKED_SYMBOLS: { value: '', type: 'string', category: 'strategy', description: '手动停做交易对列表,支持逗号、空格或换行分隔。' }, + MANUAL_REDUCED_SYMBOL_POSITION_FACTOR: { value: 0.5, type: 'number', category: 'strategy', description: '手动减仓交易对的仓位系数。0.5 表示只开半仓。' }, + MANUAL_REDUCED_SYMBOL_SIGNAL_BOOST: { value: 1, type: 'number', category: 'strategy', description: '手动减仓交易对额外提高的最小信号门槛。' }, + LOCK_PROFIT_STAGE1_TRIGGER_PCT: { value: 0.08, type: 'number', category: 'strategy', description: '分层锁盈第一层触发阈值(如 0.08=8%)。' }, + LOCK_PROFIT_STAGE1_PCT: { value: 0.02, type: 'number', category: 'strategy', description: '分层锁盈第一层锁利比例(如 0.02=2%)。' }, + LOCK_PROFIT_STAGE2_TRIGGER_PCT: { value: 0.15, type: 'number', category: 'strategy', description: '分层锁盈第二层触发阈值(如 0.15=15%)。' }, + LOCK_PROFIT_STAGE2_PCT: { value: 0.05, type: 'number', category: 'strategy', description: '分层锁盈第二层锁利比例(如 0.05=5%)。' }, } const loadConfigs = async () => { diff --git a/trading_system/config.py b/trading_system/config.py index 17312c5..f3e4332 100644 --- a/trading_system/config.py +++ b/trading_system/config.py @@ -323,6 +323,11 @@ DEFAULT_TRADING_CONFIG = { # 盈利保护:保本含手续费,避免“保本”实为小亏(在 PROFIT_PROTECTION_ENABLED 为 True 时生效) 'FEE_BUFFER_PCT': 0.0015, # 保本价相对入场价缓冲(0.15% 覆盖开平双向手续费) 'LOCK_PROFIT_AT_BREAKEVEN_AFTER_PCT': 0.03, # 盈利达3%保证金时移至保本(建议3%~5%平衡「少亏」与「拿住趋势」,0=关闭) + # 分层锁盈:在“保本”和“全量移动止损”之间增加两级台阶,减少盈利回吐 + 'LOCK_PROFIT_STAGE1_TRIGGER_PCT': 0.08, # 盈利达8%保证金后,进入第一层锁盈 + 'LOCK_PROFIT_STAGE1_PCT': 0.02, # 第一层锁住约2%保证金利润 + 'LOCK_PROFIT_STAGE2_TRIGGER_PCT': 0.15, # 盈利达15%保证金后,进入第二层锁盈 + 'LOCK_PROFIT_STAGE2_PCT': 0.05, # 第二层锁住约5%保证金利润 # 最小持仓时间锁:立即取消!山寨币30分钟可能暴涨暴跌50% 'MIN_HOLD_TIME_SEC': 0, # 取消持仓时间锁 @@ -331,8 +336,14 @@ DEFAULT_TRADING_CONFIG = { # ===== 自动交易过滤(用于提升胜率/控频)===== # 是否仅在 marketRegime=trending 时才自动交易;否则只生成推荐 'AUTO_TRADE_ONLY_TRENDING': True, + # 当不强制 only_trending 时,是否允许在震荡市自动交易;默认关闭,避免震荡扫损 + 'AUTO_TRADE_ALLOW_RANGING': False, + # 当 marketRegime=unknown(K线不足/未判定)时,是否允许自动交易;默认关闭 + 'AUTO_TRADE_ALLOW_UNKNOWN': False, # 是否允许 4H 趋势为 neutral 时自动交易(山寨短线:默认开,4H 中性也开单以多抓机会;偏保守可改为 False) 'AUTO_TRADE_ALLOW_4H_NEUTRAL': True, + # 震荡市额外提高的最小信号门槛(0-3 建议范围);默认 +2,减少边缘信号 + 'RANGING_MARKET_SIGNAL_BOOST': 2, # ===== 趋势入场过滤(防止追在半山腰)===== # 是否启用基于趋势状态的入场过滤: diff --git a/trading_system/position_manager.py b/trading_system/position_manager.py index d10d7a6..6187bfa 100644 --- a/trading_system/position_manager.py +++ b/trading_system/position_manager.py @@ -1032,6 +1032,7 @@ class PositionManager: 'stagnationExitTriggered': False, # 是否已执行滞涨分批减仓+抬止损(只执行一次) 'trailingStopActivated': False, # 移动止损是否已激活 'breakevenStopSet': False, # 是否已执行“盈利达阈值后移至保本” + 'profitLockStage': 0, # 分层锁盈阶段:0=未触发,1/2=已触发对应层 'account_id': self.account_id } @@ -1531,6 +1532,134 @@ class PositionManager: return entry_price + lock_amount / quantity return entry_price - lock_amount / quantity + def _normalize_ratio_config(self, value: Optional[float], default: float) -> float: + """兼容 0.08 / 8 两种写法,统一转为比例值。""" + try: + ratio = float(value if value is not None else default) + except (TypeError, ValueError): + ratio = float(default) + if ratio > 1: + ratio = ratio / 100.0 + return max(0.0, ratio) + + async def _apply_profit_lock_stage( + self, + symbol: str, + position_info: Dict, + entry_price: float, + quantity: float, + leverage: float, + pnl_amount: float, + pnl_percent_margin: float, + current_price: float, + source: str, + ) -> bool: + """ + 分层锁盈: + - 第一层:盈利达到阈值后,先从“纯保本”抬到“小幅锁利” + - 第二层:盈利进一步扩大后,将止损抬到更积极的锁利位置 + """ + side = position_info.get("side") + if side not in ("BUY", "SELL"): + return False + + stage1_trigger = self._normalize_ratio_config( + config.TRADING_CONFIG.get("LOCK_PROFIT_STAGE1_TRIGGER_PCT"), 0.08 + ) + stage1_lock = self._normalize_ratio_config( + config.TRADING_CONFIG.get("LOCK_PROFIT_STAGE1_PCT"), 0.02 + ) + stage2_trigger = self._normalize_ratio_config( + config.TRADING_CONFIG.get("LOCK_PROFIT_STAGE2_TRIGGER_PCT"), 0.15 + ) + stage2_lock = self._normalize_ratio_config( + config.TRADING_CONFIG.get("LOCK_PROFIT_STAGE2_PCT"), 0.05 + ) + + target_stage = 0 + target_lock_ratio = 0.0 + if stage2_trigger > 0 and stage2_lock > 0 and pnl_percent_margin >= stage2_trigger * 100: + target_stage = 2 + target_lock_ratio = stage2_lock + elif stage1_trigger > 0 and stage1_lock > 0 and pnl_percent_margin >= stage1_trigger * 100: + target_stage = 1 + target_lock_ratio = stage1_lock + + if target_stage <= 0 or target_lock_ratio <= 0: + return False + + lock_quantity = float(position_info.get("remainingQuantity", quantity) or quantity) + if lock_quantity <= 0: + lock_quantity = float(quantity or 0) + if lock_quantity <= 0: + return False + + lock_margin = (entry_price * lock_quantity) / leverage if leverage > 0 else (entry_price * lock_quantity) + target_lock_pct_number = target_lock_ratio * 100.0 + new_stop_loss = self._stop_price_to_lock_pct( + entry_price, side, lock_margin, lock_quantity, target_lock_pct_number + ) + breakeven = self._breakeven_stop_price(entry_price, side) + current_sl_raw = position_info.get("stopLoss") + current_sl = float(current_sl_raw) if current_sl_raw is not None else None + + should_update = False + if side == "BUY": + should_update = ( + new_stop_loss >= breakeven + and new_stop_loss < current_price + and (current_sl is None or new_stop_loss > current_sl) + ) + else: + should_update = ( + new_stop_loss <= breakeven + and new_stop_loss > current_price + and (current_sl is None or new_stop_loss < current_sl) + ) + + if not should_update: + return False + + position_info["stopLoss"] = new_stop_loss + prev_stage = int(position_info.get("profitLockStage", 0) or 0) + position_info["profitLockStage"] = max(prev_stage, target_stage) + logger.info( + f"{symbol} [{source}] 分层锁盈生效:第{target_stage}层,止损上调至 {new_stop_loss:.4f} " + f"(当前盈利 {pnl_percent_margin:.2f}% of margin,目标至少锁住 {target_lock_pct_number:.1f}%利润)" + ) + _log_trailing_stop_event( + self.account_id, + symbol, + "profit_lock_stage", + source=source, + stage=target_stage, + pnl_pct=pnl_percent_margin, + new_stop_loss=new_stop_loss, + target_lock_pct=target_lock_pct_number, + ) + try: + await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price) + _log_trailing_stop_event( + self.account_id, + symbol, + "profit_lock_stage_sync_ok", + source=source, + stage=target_stage, + new_stop_loss=new_stop_loss, + ) + except Exception as sync_e: + _log_trailing_stop_event( + self.account_id, + symbol, + "profit_lock_stage_sync_fail", + source=source, + stage=target_stage, + new_stop_loss=new_stop_loss, + error=str(sync_e), + ) + logger.warning(f"{symbol} [{source}] 分层锁盈同步至交易所失败: {sync_e}", exc_info=False) + return True + async def _ensure_exchange_sltp_orders(self, symbol: str, position_info: Dict, current_price: Optional[float] = None) -> None: """ 在币安侧挂止损/止盈保护单(STOP_MARKET + TAKE_PROFIT_MARKET)。 @@ -2239,6 +2368,17 @@ class PositionManager: f"{symbol} [定时检查] 同步保本止损至交易所失败: {type(sync_e).__name__}: {sync_e}", exc_info=False, ) + await self._apply_profit_lock_stage( + symbol=symbol, + position_info=position_info, + entry_price=entry_price, + quantity=quantity, + leverage=leverage, + pnl_amount=pnl_amount, + pnl_percent_margin=pnl_percent_margin, + current_price=current_price, + source="定时检查", + ) # 盈利超过阈值后(相对于保证金),激活移动止损 if pnl_percent_margin > trailing_activation * 100: position_info['trailingStopActivated'] = True @@ -4095,7 +4235,8 @@ class PositionManager: 'atr': None, 'maxProfit': 0.0, 'trailingStopActivated': False, - 'breakevenStopSet': False + 'breakevenStopSet': False, + 'profitLockStage': 0, } # 订单统一由自动下单入 DB,此处仅做内存监控不创建 DB 记录 if not sync_create_manual and DB_AVAILABLE and Trade and not config.TRADING_CONFIG.get("ONLY_AUTO_TRADE_CREATES_RECORDS", True): @@ -4489,6 +4630,17 @@ class PositionManager: f"[账号{self.account_id}] {symbol} [实时监控] 同步保本止损至交易所失败: {type(sync_e).__name__}: {sync_e}", exc_info=False, ) + await self._apply_profit_lock_stage( + symbol=symbol, + position_info=position_info, + entry_price=entry_price, + quantity=quantity, + leverage=leverage, + pnl_amount=pnl_amount, + pnl_percent_margin=pnl_percent_margin, + current_price=current_price_float, + source="实时监控", + ) # 盈利超过阈值后(相对于保证金),激活移动止损 if pnl_percent_margin > trailing_activation * 100: position_info['trailingStopActivated'] = True diff --git a/trading_system/risk_manager.py b/trading_system/risk_manager.py index ceb87bd..08e97a3 100644 --- a/trading_system/risk_manager.py +++ b/trading_system/risk_manager.py @@ -10,10 +10,12 @@ try: from .binance_client import BinanceClient from . import config from .atr_strategy import ATRStrategy + from .symbol_policy import resolve_symbol_trading_policy except ImportError: from binance_client import BinanceClient import config from atr_strategy import ATRStrategy + from symbol_policy import resolve_symbol_trading_policy logger = logging.getLogger(__name__) @@ -780,21 +782,20 @@ class RiskManager: except (TypeError, ValueError): position_scale = 1.0 - # 基于 7 天统计的 symbol / 小时软黑名单:差标的 & 差时段进一步缩小仓位 + # 基于 symbol 分层与时段过滤进一步缩小仓位: + # - reduced symbol:缩小仓位 + # - bad hour:缩小仓位 stats_scale = 1.0 try: from datetime import datetime, timezone, timedelta from config_manager import GlobalStrategyConfigManager mgr = GlobalStrategyConfigManager() - stats_symbol = mgr.get("STATS_SYMBOL_FILTERS", {}) or {} stats_hour = mgr.get("STATS_HOUR_FILTERS", {}) or {} - bl = stats_symbol.get("blacklist") or [] - if symbol and isinstance(bl, list): - if any((item.get("symbol") or "").upper() == symbol.upper() for item in bl): - # 软黑名单:默认 0.5 倍仓位 - stats_scale *= 0.5 + symbol_policy = resolve_symbol_trading_policy(symbol) + if symbol_policy.get("reduced"): + stats_scale *= float(symbol_policy.get("position_factor") or 1.0) # 小时过滤 try: @@ -871,6 +872,16 @@ class RiskManager: logger.info(f"{symbol} 自动交易已关闭(AUTO_TRADE_ENABLED=false),跳过") return False + # 交易对分层:停做名单直接拒绝自动开仓。 + try: + symbol_policy = resolve_symbol_trading_policy(symbol) + if symbol_policy.get("blocked"): + sources = ",".join(symbol_policy.get("sources") or []) or "blocked" + logger.info(f"{symbol} 命中停做名单({sources}),跳过自动开仓") + return False + except Exception as e: + logger.debug(f"{symbol} 检查交易对分层策略失败(忽略): {e}") + # 检查最小涨跌幅阈值 if abs(change_percent) < config.TRADING_CONFIG['MIN_CHANGE_PERCENT']: logger.debug(f"{symbol} 涨跌幅 {change_percent:.2f}% 小于阈值") diff --git a/trading_system/strategy.py b/trading_system/strategy.py index efa31bb..9b8559c 100644 --- a/trading_system/strategy.py +++ b/trading_system/strategy.py @@ -12,12 +12,14 @@ try: from .risk_manager import RiskManager from .position_manager import PositionManager from . import config + from .symbol_policy import resolve_symbol_trading_policy except ImportError: from binance_client import BinanceClient from market_scanner import MarketScanner from risk_manager import RiskManager from position_manager import PositionManager import config + from symbol_policy import resolve_symbol_trading_policy logger = logging.getLogger(__name__) @@ -175,12 +177,27 @@ class TradingStrategy: # 提升胜率:可配置的“仅 trending 自动交易”过滤 only_trending = bool(config.TRADING_CONFIG.get("AUTO_TRADE_ONLY_TRENDING", True)) + allow_ranging = bool(config.TRADING_CONFIG.get("AUTO_TRADE_ALLOW_RANGING", False)) + allow_unknown = bool(config.TRADING_CONFIG.get("AUTO_TRADE_ALLOW_UNKNOWN", False)) if only_trending and market_regime != 'trending': logger.info( f"{symbol} 市场状态={market_regime},跳过自动交易(仅生成推荐)" f"|原因:AUTO_TRADE_ONLY_TRENDING=true" ) continue + if not only_trending: + if market_regime == 'ranging' and not allow_ranging: + logger.info( + f"{symbol} 市场状态={market_regime},跳过自动交易(仅生成推荐)" + f"|原因:AUTO_TRADE_ALLOW_RANGING=false" + ) + continue + if market_regime not in ('trending', 'ranging') and not allow_unknown: + logger.info( + f"{symbol} 市场状态={market_regime},跳过自动交易(仅生成推荐)" + f"|原因:AUTO_TRADE_ALLOW_UNKNOWN=false" + ) + continue # 检查是否应该自动交易(已有持仓则跳过自动交易,但推荐已生成) if not await self.risk_manager.should_trade(symbol, change_percent): @@ -702,30 +719,25 @@ class TradingStrategy: # 判断是否应该交易(信号强度 >= 有效 MIN_SIGNAL_STRENGTH 才交易;低波动期自动提高门槛) base_min_signal_strength = config.get_effective_config('MIN_SIGNAL_STRENGTH', 7) - # 基于全局 7 天统计的 symbol / 小时过滤: - # - 硬黑名单:直接禁止自动交易(仅允许平仓) - # - 软黑名单 & 差时段:提高信号门槛 + # 基于交易对分层与按小时过滤: + # - blocked:直接禁止自动交易(仅允许平仓) + # - reduced / 差时段:提高信号门槛 extra_signal_boost = 0 blocked_by_hard_blacklist = False + symbol_policy_sources = [] try: from datetime import datetime, timezone, timedelta from config_manager import GlobalStrategyConfigManager mgr = GlobalStrategyConfigManager() - stats_symbol = mgr.get("STATS_SYMBOL_FILTERS", {}) or {} stats_hour = mgr.get("STATS_HOUR_FILTERS", {}) or {} - # symbol 黑名单:硬黑名单直接拒绝;软黑名单在基础门槛上 +1 - bl = stats_symbol.get("blacklist") or [] - if symbol and isinstance(bl, list): - for item in bl: - if (item.get("symbol") or "").upper() == symbol.upper(): - mode = (item.get("mode") or "reduced").lower() - if mode == "hard": - blocked_by_hard_blacklist = True - else: - extra_signal_boost += 1 - break + symbol_policy = resolve_symbol_trading_policy(symbol) + symbol_policy_sources = symbol_policy.get("sources") or [] + if symbol_policy.get("blocked"): + blocked_by_hard_blacklist = True + elif symbol_policy.get("reduced"): + extra_signal_boost += int(symbol_policy.get("signal_boost") or 0) # 按小时过滤:bad 小时再 +1 try: @@ -744,6 +756,13 @@ class TradingStrategy: extra_signal_boost = 0 blocked_by_hard_blacklist = False + mr = (market_regime or 'unknown').strip().lower() + if mr == 'ranging': + try: + extra_signal_boost += int(config.TRADING_CONFIG.get('RANGING_MARKET_SIGNAL_BOOST', 2) or 0) + except Exception: + extra_signal_boost += 2 + min_signal_strength = max(0, min(10, int(base_min_signal_strength) + int(extra_signal_boost))) # 强度上限归一到 0-10,避免出现 12/10 这种误导显示 try: @@ -764,7 +783,10 @@ class TradingStrategy: reasons.append("采用扫描器综合信号(弱)") should_trade = (not blocked_by_hard_blacklist) and signal_strength >= min_signal_strength and direction is not None if blocked_by_hard_blacklist: - reasons.append("❌ 命中硬黑名单(7天持续净亏),自动交易禁用,仅允许手动/解封后再交易") + src = " / ".join(symbol_policy_sources) if symbol_policy_sources else "hard blacklist" + reasons.append(f"❌ 命中停做名单({src}),自动交易禁用,仅允许手动/解封后再交易") + elif extra_signal_boost > 0 and direction is not None: + reasons.append(f"命中减仓观察名单/差时段/震荡市过滤,最低信号门槛提高 +{extra_signal_boost}") # ===== RSI / 24h 涨跌幅过滤:做多不追高、做空不杀跌;可选 RSI 极端反向(超买反空/超卖反多)===== # 反向属于“逆短期超买超卖”的均值回归,在强趋势里逆势风险大;可选“仅4H中性时反向”以降低风险。 diff --git a/trading_system/symbol_policy.py b/trading_system/symbol_policy.py new file mode 100644 index 0000000..2360d30 --- /dev/null +++ b/trading_system/symbol_policy.py @@ -0,0 +1,134 @@ +""" +交易对分层策略: +- 正常:按原策略处理 +- reduced:缩小仓位,并在策略侧提高信号门槛 +- blocked:直接禁止自动开仓 +""" +import logging +import re +from typing import Any, Dict, Set + +logger = logging.getLogger(__name__) + + +def _parse_symbol_list(raw: Any) -> Set[str]: + """兼容 string/list/dict 三种配置格式,统一转成大写 symbol 集合。""" + if raw is None: + return set() + + if isinstance(raw, dict): + if "symbols" in raw: + return _parse_symbol_list(raw.get("symbols")) + if "items" in raw: + return _parse_symbol_list(raw.get("items")) + return set() + + if isinstance(raw, str): + parts = re.split(r"[\s,;,\n\r\t]+", raw) + return {p.strip().upper() for p in parts if p and p.strip()} + + if isinstance(raw, list): + items: Set[str] = set() + for item in raw: + if isinstance(item, str): + sym = item.strip() + elif isinstance(item, dict): + sym = str(item.get("symbol") or item.get("value") or "").strip() + else: + sym = "" + if sym: + items.add(sym.upper()) + return items + + return set() + + +def _safe_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def resolve_symbol_trading_policy(symbol: str) -> Dict[str, Any]: + """ + 合并手动名单与统计名单,返回统一的交易对分层结果。 + + 返回字段: + - blocked: 是否禁止自动开仓 + - reduced: 是否进入减仓观察名单 + - position_factor: reduced 时建议的仓位系数 + - signal_boost: reduced 时建议提高的最小信号门槛 + - sources: 命中的来源,便于日志定位 + """ + policy = { + "blocked": False, + "reduced": False, + "position_factor": 1.0, + "signal_boost": 0, + "sources": [], + } + symbol = (symbol or "").strip().upper() + if not symbol: + return policy + + try: + from config_manager import GlobalStrategyConfigManager + + mgr = GlobalStrategyConfigManager() + stats_symbol = mgr.get("STATS_SYMBOL_FILTERS", {}) or {} + + manual_blocked = _parse_symbol_list(mgr.get("MANUAL_BLOCKED_SYMBOLS", "")) + manual_reduced = _parse_symbol_list(mgr.get("MANUAL_REDUCED_SYMBOLS", "")) + + manual_position_factor = _safe_float( + mgr.get("MANUAL_REDUCED_SYMBOL_POSITION_FACTOR", 0.5), 0.5 + ) + manual_position_factor = max(0.05, min(manual_position_factor, 1.0)) + + manual_signal_boost = _safe_int( + mgr.get("MANUAL_REDUCED_SYMBOL_SIGNAL_BOOST", 1), 1 + ) + manual_signal_boost = max(0, min(manual_signal_boost, 5)) + + if symbol in manual_blocked: + policy["blocked"] = True + policy["sources"].append("manual_blocked") + + if symbol in manual_reduced: + policy["reduced"] = True + policy["position_factor"] = min(policy["position_factor"], manual_position_factor) + policy["signal_boost"] = max(policy["signal_boost"], manual_signal_boost) + policy["sources"].append("manual_reduced") + + for item in (stats_symbol.get("blacklist") or []): + if (item.get("symbol") or "").strip().upper() != symbol: + continue + mode = (item.get("mode") or "reduced").strip().lower() + if mode == "hard": + policy["blocked"] = True + policy["sources"].append("stats_hard") + else: + policy["reduced"] = True + policy["position_factor"] = min(policy["position_factor"], 0.5) + policy["signal_boost"] = max(policy["signal_boost"], 1) + policy["sources"].append("stats_reduced") + break + except Exception as e: + logger.debug(f"{symbol} 读取交易对分层策略失败: {e}") + + if policy["blocked"]: + policy["reduced"] = False + policy["position_factor"] = 1.0 + policy["signal_boost"] = 0 + + # 去重并保留顺序,便于日志阅读。 + policy["sources"] = list(dict.fromkeys(policy["sources"])) + return policy