diff --git a/config/my_batch_volatility_pullback.json b/config/my_batch_volatility_pullback.json new file mode 100644 index 0000000..64779a1 --- /dev/null +++ b/config/my_batch_volatility_pullback.json @@ -0,0 +1,251 @@ +[ + { + "key": "VOLATILITY_PULLBACK_ENABLED", + "value": true, + "type": "boolean", + "category": "strategy", + "description": "启用波动回调策略:4H+1H同向,1H区间极值,15m波动触发" + }, + { + "key": "VOLATILITY_PULLBACK_AUTO_ONLY", + "value": true, + "type": "boolean", + "category": "strategy", + "description": "自动交易仅使用VP信号,不fallback MACD趋势" + }, + { + "key": "VOLATILITY_PULLBACK_SKIP_REGIME_GATE", + "value": true, + "type": "boolean", + "category": "strategy", + "description": "VP信号跳过 ranging/trending 门禁" + }, + { + "key": "VP_INSTANT_FLUSH_ENABLED", + "value": true, + "type": "boolean", + "category": "strategy", + "description": "即时模式:进行中1H快速波动+前结构低/高点入场" + }, + { + "key": "VP_PRIOR_STRUCTURE_BARS", + "value": 12, + "type": "number", + "category": "strategy", + "description": "前结构位:已完成1H K线根数(不含当前进行中一根)" + }, + { + "key": "VP_1H_FLUSH_MIN_PCT", + "value": 0.005, + "type": "number", + "category": "strategy", + "description": "进行中1H从开盘下冲/上冲至少0.5%" + }, + { + "key": "VP_NEAR_PRIOR_LOW_PCT", + "value": 0.008, + "type": "number", + "category": "strategy", + "description": "做多:现价在前低上方0.8%内" + }, + { + "key": "VP_MAX_BELOW_PRIOR_LOW_PCT", + "value": 0.004, + "type": "number", + "category": "strategy", + "description": "做多:允许刺破前低0.4%仍算入场区" + }, + { + "key": "VP_NEAR_PRIOR_HIGH_PCT", + "value": 0.008, + "type": "number", + "category": "strategy" + }, + { + "key": "VP_MAX_ABOVE_PRIOR_HIGH_PCT", + "value": 0.004, + "type": "number", + "category": "strategy" + }, + { + "key": "VP_VOL_LOOKBACK_BARS", + "value": 3, + "type": "number", + "category": "strategy", + "description": "15m波动:近3根(约45分钟)" + }, + { + "key": "VP_LONG_MIN_DROP_PCT", + "value": 0.006, + "type": "number", + "category": "strategy", + "description": "15m合计跌0.6%可作为辅助触发" + }, + { + "key": "VP_SHORT_MIN_RISE_PCT", + "value": 0.006, + "type": "number", + "category": "strategy" + }, + { + "key": "VP_SCAN_INTERVAL_SEC", + "value": 180, + "type": "number", + "category": "scan", + "description": "VP启用时扫描间隔180秒(3分钟)" + }, + { + "key": "SCAN_INTERVAL", + "value": 180, + "type": "number", + "category": "scan", + "description": "与VP_SCAN一致,提高抓取频率" + }, + { + "key": "VP_REQUIRE_1H_4H_ALIGN", + "value": false, + "type": "boolean", + "category": "strategy", + "description": "即时模式下仅4H定方向(急跌抄底不要求1H同向)" + }, + { + "key": "VP_RANGE_INTERVAL", + "value": "1h", + "type": "string", + "category": "strategy", + "description": "区间锚定周期" + }, + { + "key": "VP_RANGE_LOOKBACK_BARS", + "value": 24, + "type": "number", + "category": "strategy", + "description": "1H区间回看根数" + }, + { + "key": "VP_MAX_LONG_IN_RANGE", + "value": 0.42, + "type": "number", + "category": "strategy", + "description": "做多:1H区间位置上限(靠近前低)" + }, + { + "key": "VP_MIN_SHORT_IN_RANGE", + "value": 0.58, + "type": "number", + "category": "strategy", + "description": "做空:1H区间位置下限(靠近前高)" + }, + { + "key": "VP_VOL_INTERVAL", + "value": "15m", + "type": "string", + "category": "strategy", + "description": "瞬时波动观察周期" + }, + { + "key": "VP_VOL_LOOKBACK_BARS", + "value": 5, + "type": "number", + "category": "strategy", + "description": "15m波动计算根数" + }, + { + "key": "VP_LONG_MIN_DROP_PCT", + "value": 0.008, + "type": "number", + "category": "strategy", + "description": "做多:15m合计跌幅≥0.8%" + }, + { + "key": "VP_SHORT_MIN_RISE_PCT", + "value": 0.008, + "type": "number", + "category": "strategy", + "description": "做空:15m合计涨幅≥0.8%" + }, + { + "key": "VP_SIGNAL_STRENGTH", + "value": 8, + "type": "number", + "category": "strategy", + "description": "VP信号强度(用于杠杆/日志)" + }, + { + "key": "VP_ATR_STOP_MULTIPLIER", + "value": 1.2, + "type": "number", + "category": "risk", + "description": "VP止损:ATR倍数(相对1H ATR)" + }, + { + "key": "VP_STRUCTURE_STOP_BUFFER_PCT", + "value": 0.003, + "type": "number", + "category": "risk", + "description": "结构止损:1H前低/前高外缓冲0.3%" + }, + { + "key": "AUTO_TRADE_SYMBOL_WHITELIST", + "value": "BTCUSDT,ETHUSDT,BNBUSDT,SOLUSDT,LINKUSDT,DOGEUSDT,AVAXUSDT,XRPUSDT", + "type": "string", + "category": "strategy", + "description": "混合白名单" + }, + { + "key": "MANUAL_REDUCED_SYMBOLS", + "value": "LINKUSDT,DOGEUSDT,AVAXUSDT,XRPUSDT", + "type": "string", + "category": "strategy", + "description": "山寨减仓名单" + }, + { + "key": "MANUAL_REDUCED_SYMBOL_POSITION_FACTOR", + "value": 0.5, + "type": "number", + "category": "strategy" + }, + { + "key": "MANUAL_REDUCED_SYMBOL_SIGNAL_BOOST", + "value": 1, + "type": "number", + "category": "strategy" + }, + { + "key": "AUTO_TRADE_ONLY_TRENDING", + "value": false, + "type": "boolean", + "category": "strategy" + }, + { + "key": "AUTO_TRADE_ALLOW_RANGING", + "value": true, + "type": "boolean", + "category": "strategy" + }, + { + "key": "BETA_FILTER_ENABLED", + "value": true, + "type": "boolean", + "category": "strategy" + }, + { + "key": "USE_TREND_ENTRY_FILTER", + "value": false, + "type": "boolean", + "category": "strategy", + "description": "VP模式下关闭趋势追价过滤" + }, + { + "key": "NO_OPEN_HOURS_BJ", + "value": "", + "type": "string", + "category": "risk" + }, + { + "key": "NIGHT_HOURS_NO_OPEN_ENABLED", + "value": false, + "type": "boolean", + "category": "risk" + } +] diff --git a/scripts/apply_volatility_pullback.sh b/scripts/apply_volatility_pullback.sh new file mode 100755 index 0000000..b9c618c --- /dev/null +++ b/scripts/apply_volatility_pullback.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# 波动回调策略 + 混合白名单(多空) +# +# 规则:4H+1H 同向 | 1H 区间靠近前低/前高 | 15m 急跌/急涨触发 +# 详见 config/my_batch_volatility_pullback.json +# +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +PYTHON_BIN="" +for _py in \ + "${ROOT}/.venv/bin/python" \ + "${ROOT}/trading_system/.venv/bin/python" \ + "${ROOT}/backend/.venv/bin/python"; do + [ -x "$_py" ] && PYTHON_BIN="$_py" && break +done +PYTHON_BIN="${PYTHON_BIN:-$(command -v python3)}" + +DRY_RUN=() +[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=(--dry-run) + +exec "$PYTHON_BIN" "$ROOT/scripts/apply_config_patch_only.py" \ + --from "$ROOT/config/my_batch_volatility_pullback.json" \ + --updated-by "volatility_pullback" \ + "${DRY_RUN[@]}" diff --git a/trading_system/config.py b/trading_system/config.py index 09040fa..6144a4c 100644 --- a/trading_system/config.py +++ b/trading_system/config.py @@ -389,6 +389,42 @@ DEFAULT_TRADING_CONFIG = { 'ENTRY_PULLBACK_MAX_LONG_IN_RANGE': 0.62, # 做多:pos 超过此值则不开(默认拒绝最上约 38% 区域) 'ENTRY_PULLBACK_MIN_SHORT_IN_RANGE': 0.38, # 做空:pos 低于此值则不开(默认拒绝最下约 38% 区域) + # ===== 波动回调入场(4H+1H 定方向,1H 区间低点/高点,15m 波动触发)===== + 'VOLATILITY_PULLBACK_ENABLED': False, + 'VOLATILITY_PULLBACK_AUTO_ONLY': True, + 'VOLATILITY_PULLBACK_SKIP_REGIME_GATE': True, + 'VP_INSTANT_FLUSH_ENABLED': True, + 'VP_SCAN_INTERVAL_SEC': 180, + 'VP_PRIOR_STRUCTURE_BARS': 12, + 'VP_1H_FLUSH_MIN_PCT': 0.005, + 'VP_NEAR_PRIOR_LOW_PCT': 0.008, + 'VP_MAX_BELOW_PRIOR_LOW_PCT': 0.004, + 'VP_NEAR_PRIOR_HIGH_PCT': 0.008, + 'VP_MAX_ABOVE_PRIOR_HIGH_PCT': 0.004, + 'VP_REQUIRE_1H_4H_ALIGN': True, + 'VP_RANGE_INTERVAL': '1h', + 'VP_RANGE_LOOKBACK_BARS': 24, + 'VP_RANGE_MIN_BARS': 5, + 'VP_MAX_LONG_IN_RANGE': 0.42, + 'VP_MIN_SHORT_IN_RANGE': 0.58, + 'VP_VOL_INTERVAL': '15m', + 'VP_VOL_LOOKBACK_BARS': 5, + 'VP_LONG_MIN_DROP_PCT': 0.008, + 'VP_SHORT_MIN_RISE_PCT': 0.008, + 'VP_SIGNAL_STRENGTH': 8, + 'VP_ATR_STOP_MULTIPLIER': 1.2, + 'VP_STRUCTURE_STOP_BUFFER_PCT': 0.003, + + # 即时波动模式:进行中 1H K 线快速波动 + 前结构低/高点 + 'VP_INSTANT_FLUSH_ENABLED': True, + 'VP_PRIOR_STRUCTURE_BARS': 12, + 'VP_1H_FLUSH_MIN_PCT': 0.005, + 'VP_NEAR_PRIOR_LOW_PCT': 0.008, + 'VP_MAX_BELOW_PRIOR_LOW_PCT': 0.004, + 'VP_NEAR_PRIOR_HIGH_PCT': 0.008, + 'VP_MAX_ABOVE_PRIOR_HIGH_PCT': 0.004, + 'VP_SCAN_INTERVAL_SEC': 180, + # ===== 智能入场(方案C)===== # 根治方案:默认关闭。关闭后回归“纯限价单模式”(不追价/不市价兜底/未成交撤单跳过) 'SMART_ENTRY_ENABLED': True, # 开启智能入场,提高成交率 diff --git a/trading_system/position_manager.py b/trading_system/position_manager.py index e941c2f..89c10fb 100644 --- a/trading_system/position_manager.py +++ b/trading_system/position_manager.py @@ -444,6 +444,11 @@ class PositionManager: # ===== 趋势入场过滤(防止在趋势尾部追价)===== try: use_trend_filter = bool(config.TRADING_CONFIG.get("USE_TREND_ENTRY_FILTER", False)) + if ( + entry_context + and entry_context.get("strategy_type") == "volatility_pullback" + ): + use_trend_filter = False if use_trend_filter and getattr(self.client, "redis_cache", None): trend_state_key = f"trend_state:{symbol}" trend_state = await self.client.redis_cache.get(trend_state_key) @@ -493,6 +498,11 @@ class PositionManager: # ===== 回撤/区间入场过滤(参考近 N 根 K 线的前低/前高,避免买在区间顶部、卖在区间底部)===== try: pb_enabled = bool(config.TRADING_CONFIG.get("ENTRY_PULLBACK_FILTER_ENABLED", False)) + if ( + entry_context + and entry_context.get("strategy_type") == "volatility_pullback" + ): + pb_enabled = False if pb_enabled: min_bars = max(3, int(config.TRADING_CONFIG.get("ENTRY_PULLBACK_MIN_BARS", 5) or 5)) lookback = max(min_bars, int(config.TRADING_CONFIG.get("ENTRY_PULLBACK_LOOKBACK_BARS", 24) or 24)) @@ -545,12 +555,40 @@ class PositionManager: # 估算止损价格(用于固定风险计算) estimated_stop_loss = None + vp_meta = (entry_context or {}).get("volatility_pullback") if entry_context else None + vp_mult = None + if entry_context and entry_context.get("strategy_type") == "volatility_pullback": + try: + vp_mult = float(config.TRADING_CONFIG.get("VP_ATR_STOP_MULTIPLIER", 1.2) or 1.2) + except (TypeError, ValueError): + vp_mult = 1.2 + atr_multiplier = vp_mult if vp_mult else config.TRADING_CONFIG.get('ATR_STOP_LOSS_MULTIPLIER', 2.5) if atr and atr > 0: - atr_multiplier = config.TRADING_CONFIG.get('ATR_STOP_LOSS_MULTIPLIER', 2.5) # 默认2.5,放宽止损提升胜率 if estimated_side == 'BUY': estimated_stop_loss = estimated_entry_price - (atr * atr_multiplier) - else: # SELL + else: estimated_stop_loss = estimated_entry_price + (atr * atr_multiplier) + if isinstance(vp_meta, dict) and vp_meta: + try: + buf = float(config.TRADING_CONFIG.get("VP_STRUCTURE_STOP_BUFFER_PCT", 0.003) or 0.003) + except (TypeError, ValueError): + buf = 0.003 + if estimated_side == 'BUY': + ref_low = vp_meta.get("prior_structure_low") or vp_meta.get("recent_low") + if ref_low: + struct_sl = float(ref_low) * (1.0 - buf) + if estimated_stop_loss is None: + estimated_stop_loss = struct_sl + else: + estimated_stop_loss = max(estimated_stop_loss, struct_sl) + elif estimated_side == 'SELL': + ref_high = vp_meta.get("prior_structure_high") or vp_meta.get("recent_high") + if ref_high: + struct_sl = float(ref_high) * (1.0 + buf) + if estimated_stop_loss is None: + estimated_stop_loss = struct_sl + else: + estimated_stop_loss = min(estimated_stop_loss, struct_sl) quantity, adjusted_leverage = await self.risk_manager.calculate_position_size( symbol, change_percent, leverage=leverage, diff --git a/trading_system/signal_filters.py b/trading_system/signal_filters.py index 344fd80..c7d40bd 100644 --- a/trading_system/signal_filters.py +++ b/trading_system/signal_filters.py @@ -55,6 +55,13 @@ async def apply_signal_filters(ctx: SignalFilterContext) -> SignalFilterResult: reasons: List[str] = [] accepted = True + if ctx.trade_signal.get("strategy_type") == "volatility_pullback": + if not _filter_by_volatility(ctx, reasons): + accepted = False + if accepted and not _filter_by_strict_volume(ctx, reasons): + accepted = False + return SignalFilterResult(accepted=accepted, reasons=reasons) + # 1) 波动率过滤:避免在接近「死币」的环境下做趋势单 if not _filter_by_volatility(ctx, reasons): accepted = False diff --git a/trading_system/strategy.py b/trading_system/strategy.py index cb5b1d6..1e9a1eb 100644 --- a/trading_system/strategy.py +++ b/trading_system/strategy.py @@ -15,6 +15,7 @@ try: from . import config from .symbol_policy import resolve_symbol_trading_policy, auto_trade_whitelist_allows from .shadow_mode import evaluate_shadow_mode, invalidate_cache as shadow_invalidate_cache + from .volatility_pullback import evaluate_volatility_pullback except ImportError: from binance_client import BinanceClient from market_scanner import MarketScanner @@ -24,6 +25,7 @@ except ImportError: import config from symbol_policy import resolve_symbol_trading_policy, auto_trade_whitelist_allows from shadow_mode import evaluate_shadow_mode, invalidate_cache as shadow_invalidate_cache + from volatility_pullback import evaluate_volatility_pullback logger = logging.getLogger(__name__) @@ -124,7 +126,7 @@ class TradingStrategy: if not top_symbols: logger.warning("未找到符合条件的交易对,等待下次扫描...") - await self._wait_for_next_scan(config.TRADING_CONFIG.get('SCAN_INTERVAL', 3600)) + await self._wait_for_next_scan(self._effective_scan_interval()) continue # 2. 对每个交易对执行交易逻辑(使用技术指标) @@ -142,7 +144,7 @@ class TradingStrategy: f"({direction} {change_percent:.2f}%, 市场状态: {market_regime})" ) - # 使用技术指标判断交易信号(高胜率策略) + # 使用技术指标判断交易信号(推荐/日志);自动交易可另走波动回调(VP) trade_signal = await self._analyze_trade_signal(symbol_info) # 记录交易信号到数据库(只有当有明确方向时才记录) @@ -191,30 +193,6 @@ class TradingStrategy: ) continue - # 提升胜率:可配置的“仅 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): continue @@ -223,35 +201,65 @@ class TradingStrategy: if not await self._check_volume_confirmation(symbol_info): logger.info(f"{symbol} 成交量确认失败,跳过") continue - - if not trade_signal['should_trade']: + + auto_signal = await self._resolve_auto_trade_signal(symbol, symbol_info, trade_signal) + + if not auto_signal['should_trade']: logger.info( - f"{symbol} 技术指标分析: {trade_signal['reason']}, 跳过自动交易" + f"{symbol} 信号未通过: {auto_signal['reason']}, 跳过自动交易" ) continue + # 提升胜率:可配置的“仅 trending 自动交易”过滤(波动回调策略可跳过) + skip_regime = bool(config.TRADING_CONFIG.get("VOLATILITY_PULLBACK_SKIP_REGIME_GATE", False)) and ( + auto_signal.get("strategy_type") == "volatility_pullback" + ) + 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 not skip_regime: + 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 + # 统一的信号过滤插件层(第一阶段:波动率等「进一步收紧」过滤) try: sf_ctx = SignalFilterContext( symbol=symbol, symbol_info=symbol_info, - trade_signal=trade_signal, + trade_signal=auto_signal, market_regime=market_regime, ) sf_result = await apply_signal_filters(sf_ctx) if not sf_result.accepted: # 将过滤原因拼接到 trade_signal 的 reason 里,便于后续分析 extra_reason = " | ".join(sf_result.reasons) if sf_result.reasons else "信号过滤未通过" - base_reason = trade_signal.get("reason") or "" + base_reason = auto_signal.get("reason") or "" combined_reason = f"{base_reason} | {extra_reason}" if base_reason else extra_reason - trade_signal["reason"] = combined_reason + auto_signal["reason"] = combined_reason logger.info(f"{symbol} 信号过滤未通过,跳过自动交易: {combined_reason}") continue elif sf_result.reasons: # 若过滤层仅追加了评语(accepted=True),也拼接到 reason 便于排查 extra_reason = " | ".join(sf_result.reasons) - base_reason = trade_signal.get("reason") or "" - trade_signal["reason"] = f"{base_reason} | {extra_reason}" if base_reason else extra_reason + base_reason = auto_signal.get("reason") or "" + auto_signal["reason"] = f"{base_reason} | {extra_reason}" if base_reason else extra_reason except Exception as e: logger.warning(f"{symbol} 应用信号过滤插件时出错(忽略,回退为原逻辑): {e}") @@ -275,18 +283,18 @@ class TradingStrategy: ) continue - # 确定交易方向(基于技术指标) - trade_direction = trade_signal['direction'] + # 确定交易方向(自动交易信号,含 VP) + trade_direction = auto_signal['direction'] # 4H 下跌禁止开多(熊市/保守方案:避免逆势抄底) block_long_4h_down = bool(config.TRADING_CONFIG.get("BLOCK_LONG_WHEN_4H_DOWN", False)) - if block_long_4h_down and trade_direction == "BUY" and trade_signal.get("trend_4h") == "down": + if block_long_4h_down and trade_direction == "BUY" and auto_signal.get("trend_4h") == "down": logger.info( f"{symbol} 4H 趋势下跌,禁止开多(BLOCK_LONG_WHEN_4H_DOWN=true),跳过" ) continue # 4H 上涨禁止开空(对称过滤:避免逆势做空,减少上涨行情中空单止损) block_short_4h_up = bool(config.TRADING_CONFIG.get("BLOCK_SHORT_WHEN_4H_UP", True)) - if block_short_4h_up and trade_direction == "SELL" and trade_signal.get("trend_4h") == "up": + if block_short_4h_up and trade_direction == "SELL" and auto_signal.get("trend_4h") == "up": logger.info( f"{symbol} 4H 趋势上涨,禁止开空(BLOCK_SHORT_WHEN_4H_UP=true),跳过" ) @@ -313,9 +321,9 @@ class TradingStrategy: if not await self._check_liquidity_before_entry(symbol, symbol_info.get('price')): logger.info(f"{symbol} 流动性检查未通过,跳过开仓") continue - entry_reason = trade_signal['reason'] + entry_reason = auto_signal['reason'] - signal_strength = trade_signal.get('strength', 0) + signal_strength = auto_signal.get('strength', 0) logger.info( f"{symbol} 交易信号: {trade_direction} | " f"原因: {entry_reason} | " @@ -349,7 +357,10 @@ class TradingStrategy: entry_context = { 'signal_strength': signal_strength, 'market_regime': market_regime, - 'trend_4h': trade_signal.get('trend_4h'), + 'trend_4h': auto_signal.get('trend_4h'), + 'trend_1h': auto_signal.get('trend_1h'), + 'strategy_type': auto_signal.get('strategy_type'), + 'volatility_pullback': auto_signal.get('volatility_pullback') or {}, 'change_percent': change_percent, 'direction': trade_direction, 'reason': entry_reason, @@ -456,7 +467,7 @@ class TradingStrategy: logger.debug(f"重新加载配置失败: {e}") # 等待下次扫描(支持手动触发) - scan_interval = config.TRADING_CONFIG.get('SCAN_INTERVAL', 3600) + scan_interval = self._effective_scan_interval() await self._wait_for_next_scan(scan_interval) except Exception as e: @@ -478,6 +489,22 @@ class TradingStrategy: logger.warning(f"停止持仓监控时出错: {e}") logger.info("交易策略已停止") + def _effective_scan_interval(self) -> int: + """VP 启用时可使用更短扫描间隔以捕捉即时波动。""" + try: + base = int(config.TRADING_CONFIG.get("SCAN_INTERVAL", 3600) or 3600) + except (TypeError, ValueError): + base = 3600 + if not bool(config.TRADING_CONFIG.get("VOLATILITY_PULLBACK_ENABLED", False)): + return max(30, base) + try: + vp_sec = int(config.TRADING_CONFIG.get("VP_SCAN_INTERVAL_SEC", 0) or 0) + except (TypeError, ValueError): + vp_sec = 0 + if vp_sec > 0: + return max(30, min(base, vp_sec)) + return max(30, base) + async def _wait_for_next_scan(self, scan_interval: int): """ 等待下次扫描,同时监听手动触发信号 @@ -631,6 +658,43 @@ class TradingStrategy: hour_bj = datetime.now(bj).hour return evaluate_shadow_mode(symbol, hour_bj, config.TRADING_CONFIG) + async def _resolve_auto_trade_signal( + self, symbol: str, symbol_info: Dict, macd_signal: Dict + ) -> Dict: + """ + 自动交易专用信号:优先波动回调(VP);未启用或未通过时可回退 MACD 趋势信号。 + """ + vp_enabled = bool(config.TRADING_CONFIG.get("VOLATILITY_PULLBACK_ENABLED", False)) + vp_auto_only = bool(config.TRADING_CONFIG.get("VOLATILITY_PULLBACK_AUTO_ONLY", True)) + + if not vp_enabled: + return macd_signal + + trend_4h = symbol_info.get("trend_4h") + if not trend_4h: + price_4h = symbol_info.get("price_4h", symbol_info.get("price")) + trend_4h = self._judge_trend_4h( + price_4h, + symbol_info.get("ema20_4h"), + symbol_info.get("ema50_4h"), + symbol_info.get("macd_4h"), + ) + + vp_signal = await evaluate_volatility_pullback( + self.client, + symbol, + symbol_info, + trend_4h=trend_4h, + beta_checker=self._check_beta_filter, + ) + if vp_signal.get("should_trade"): + logger.info(f"{symbol} [VP] {vp_signal.get('reason')}") + return vp_signal + if vp_auto_only: + logger.info(f"{symbol} [VP] 未满足: {vp_signal.get('reason')}") + return {**vp_signal, "should_trade": False} + return macd_signal + async def _analyze_trade_signal(self, symbol_info: Dict) -> Dict: """ 使用技术指标分析交易信号(高胜率策略) diff --git a/trading_system/volatility_pullback.py b/trading_system/volatility_pullback.py new file mode 100644 index 0000000..3924110 --- /dev/null +++ b/trading_system/volatility_pullback.py @@ -0,0 +1,461 @@ +""" +波动回调入场(Volatility Pullback) + +即时波动模式(推荐): +- 大方向:4H 定多空(急跌抄底只要求 4H 向上) +- 触发:当前进行中的 1H K 线快速下冲/上冲,或 15m 短周期波动 +- 结构位:已完成 1H K 线的前低/前高(非整段区间极值)作入场与止损参考 +""" +from __future__ import annotations + +import logging +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from . import config + +logger = logging.getLogger(__name__) + +BetaChecker = Callable[[], Awaitable[Dict[str, Any]]] + + +def _cfg(key: str, default: Any) -> Any: + return config.TRADING_CONFIG.get(key, default) + + +def _pullback_range_metrics( + klines: Optional[List], + current_price: float, +) -> Optional[Tuple[float, float, float]]: + if not klines or current_price is None: + return None + try: + cur = float(current_price) + except (TypeError, ValueError): + return None + if cur <= 0: + return None + highs: List[float] = [] + lows: List[float] = [] + for k in klines: + try: + highs.append(float(k[2])) + lows.append(float(k[3])) + except (IndexError, TypeError, ValueError): + continue + if len(highs) < 2: + return None + hi = max(highs) + lo = min(lows) + if hi <= lo: + return None + pos = (cur - lo) / (hi - lo) + return lo, hi, pos + + +def judge_trend_1h( + price: Optional[float], + ema20: Optional[float], + ema50: Optional[float], +) -> str: + if price is None or ema20 is None: + return "neutral" + try: + p = float(price) + e20 = float(ema20) + except (TypeError, ValueError): + return "neutral" + e50 = None + if ema50 is not None: + try: + e50 = float(ema50) + except (TypeError, ValueError): + e50 = None + if p >= e20 and (e50 is None or e20 >= e50): + return "up" + if p <= e20 and (e50 is None or e20 <= e50): + return "down" + return "neutral" + + +def _prior_structure_levels( + klines_1h: List, + prior_bars: int, +) -> Optional[Tuple[float, float]]: + """取已完成 1H K 线(不含当前进行中一根)的前低/前高。""" + if not klines_1h or len(klines_1h) < 2: + return None + completed = klines_1h[:-1] + if not completed: + return None + slice_bars = completed[-max(1, prior_bars):] + lows: List[float] = [] + highs: List[float] = [] + for k in slice_bars: + try: + lows.append(float(k[3])) + highs.append(float(k[2])) + except (IndexError, TypeError, ValueError): + continue + if not lows or not highs: + return None + return min(lows), max(highs) + + +def _forming_1h_flush_pcts(klines_1h: List, current_price: float) -> Optional[Dict[str, float]]: + """ + 当前进行中 1H K 线内的瞬时波动幅度(相对开盘价)。 + flush_down: open -> min(low, price) + flush_up: max(high, price) -> open + """ + if not klines_1h: + return None + forming = klines_1h[-1] + try: + o = float(forming[1]) + h = float(forming[2]) + lo = float(forming[3]) + cur = float(current_price) + except (IndexError, TypeError, ValueError): + return None + if o <= 0: + return None + trough = min(lo, cur) + peak = max(h, cur) + flush_down = max(0.0, (o - trough) / o) + flush_up = max(0.0, (peak - o) / o) + return { + "open": o, + "high": h, + "low": lo, + "flush_down_pct": flush_down, + "flush_up_pct": flush_up, + } + + +def _price_near_prior_low(price: float, prior_low: float, above_pct: float, below_pct: float) -> bool: + if prior_low <= 0: + return False + upper = prior_low * (1.0 + above_pct) + lower = prior_low * (1.0 - below_pct) + return lower <= price <= upper + + +def _price_near_prior_high(price: float, prior_high: float, below_pct: float, above_pct: float) -> bool: + if prior_high <= 0: + return False + lower = prior_high * (1.0 - below_pct) + upper = prior_high * (1.0 + above_pct) + return lower <= price <= upper + + +async def _resolve_live_price(client, symbol: str, symbol_info: Dict) -> Optional[float]: + for src in ( + lambda: client.get_realtime_price(symbol) if hasattr(client, "get_realtime_price") else None, + ): + try: + v = src() + if v is not None: + return float(v) + except Exception: + pass + raw = symbol_info.get("ticker_price") or symbol_info.get("price") + try: + return float(raw) if raw is not None else None + except (TypeError, ValueError): + return None + + +async def _period_change(client, symbol: str, interval: str, periods: int) -> Optional[float]: + try: + klines = await client.get_klines(symbol, interval, limit=max(periods + 1, 3)) + if not klines or len(klines) < 2: + return None + first_close = float(klines[0][4]) + last_close = float(klines[-1][4]) + if first_close <= 0: + return None + return (last_close - first_close) / first_close + except Exception as e: + logger.debug(f"{symbol} {interval} 涨跌幅获取失败: {e}") + return None + + +async def _fetch_klines(client, symbol: str, interval: str, limit: int) -> List: + try: + rows = await client.get_klines(symbol, interval, limit=limit) + return rows or [] + except Exception as e: + logger.debug(f"{symbol} 拉取 {interval} K线失败: {e}") + return [] + + +def _fail(reason: str, trend_4h: Optional[str]) -> Dict[str, Any]: + return { + "should_trade": False, + "direction": None, + "reason": reason, + "strength": 0, + "trend_4h": trend_4h, + "trend_1h": None, + "strategy_type": "volatility_pullback", + "volatility_pullback": {}, + } + + +async def _evaluate_instant_flush( + client, + symbol: str, + symbol_info: Dict, + trend_4h: Optional[str], + price_f: float, + beta_checker: Optional[BetaChecker], +) -> Dict[str, Any]: + """1H 进行中 K 线快速波动 + 前结构低/高点入场(更贴近手动抄底/摸顶)。""" + t4 = (trend_4h or "neutral").strip().lower() + if t4 not in ("up", "down"): + return _fail(f"4H 趋势未明确(4H={t4}),即时模式需 up/down", trend_4h) + direction = "BUY" if t4 == "up" else "SELL" + + lookback = max(8, int(_cfg("VP_RANGE_LOOKBACK_BARS", 24) or 24)) + prior_bars = max(3, int(_cfg("VP_PRIOR_STRUCTURE_BARS", 12) or 12)) + bars_1h = await _fetch_klines(client, symbol, "1h", lookback + 1) + if len(bars_1h) < 4: + return _fail(f"1H K线不足({len(bars_1h)})", trend_4h) + + structure = _prior_structure_levels(bars_1h, prior_bars) + if structure is None: + return _fail("无法计算前结构低/高点", trend_4h) + prior_low, prior_high = structure + + flush = _forming_1h_flush_pcts(bars_1h, price_f) + if flush is None: + return _fail("无法解析进行中 1H K线", trend_4h) + + flush_min = float(_cfg("VP_1H_FLUSH_MIN_PCT", 0.005) or 0.005) + near_above = float(_cfg("VP_NEAR_PRIOR_LOW_PCT", 0.008) or 0.008) + near_below = float(_cfg("VP_MAX_BELOW_PRIOR_LOW_PCT", 0.004) or 0.004) + near_high_below = float(_cfg("VP_NEAR_PRIOR_HIGH_PCT", 0.008) or 0.008) + near_high_above = float(_cfg("VP_MAX_ABOVE_PRIOR_HIGH_PCT", 0.004) or 0.004) + + vol_iv = str(_cfg("VP_VOL_INTERVAL", "15m") or "15m") + vol_bars = max(2, int(_cfg("VP_VOL_LOOKBACK_BARS", 3) or 3)) + long_min_drop = float(_cfg("VP_LONG_MIN_DROP_PCT", 0.006) or 0.006) + short_min_rise = float(_cfg("VP_SHORT_MIN_RISE_PCT", 0.006) or 0.006) + + vol_change = await _period_change(client, symbol, vol_iv, vol_bars) + trend_1h = judge_trend_1h(price_f, symbol_info.get("ema20"), symbol_info.get("ema50")) + + reasons: List[str] = [] + if direction == "BUY": + flush_ok = flush["flush_down_pct"] >= flush_min + vol_ok = vol_change is not None and vol_change <= -long_min_drop + if not flush_ok and not vol_ok: + fd = flush["flush_down_pct"] * 100 + vc = (vol_change or 0) * 100 + return _fail( + f"即时波动不足:1H下冲{fd:.2f}%<{flush_min*100:.2f}% 且 " + f"{vol_iv}跌{vc:.2f}%不足-{long_min_drop*100:.2f}%", + trend_4h, + ) + if not _price_near_prior_low(price_f, prior_low, near_above, near_below): + return _fail( + f"未贴近前低:现价={price_f:.6f} 前低={prior_low:.6f} " + f"(允许上方{near_above*100:.1f}%/下方{near_below*100:.1f}%)", + trend_4h, + ) + if beta_checker is not None: + try: + beta = await beta_checker() + if beta.get("should_block_buy"): + return _fail(f"大盘共振过滤:{beta.get('reason', '禁多')}", trend_4h) + except Exception as e: + logger.debug(f"{symbol} Beta 检查异常(放行): {e}") + if flush_ok: + reasons.append(f"1H进行中下冲{flush['flush_down_pct']*100:.2f}%") + if vol_ok: + reasons.append(f"{vol_iv}跌{(vol_change or 0)*100:.2f}%") + reasons.append(f"贴近前低{prior_low:.6f}") + reasons.append(f"4H向上") + struct_key, struct_val = "prior_structure_low", prior_low + else: + flush_ok = flush["flush_up_pct"] >= flush_min + vol_ok = vol_change is not None and vol_change >= short_min_rise + if not flush_ok and not vol_ok: + fu = flush["flush_up_pct"] * 100 + vc = (vol_change or 0) * 100 + return _fail( + f"即时波动不足:1H上冲{fu:.2f}%<{flush_min*100:.2f}% 且 " + f"{vol_iv}涨{vc:.2f}%不足{short_min_rise*100:.2f}%", + trend_4h, + ) + if not _price_near_prior_high(price_f, prior_high, near_high_below, near_high_above): + return _fail( + f"未贴近前高:现价={price_f:.6f} 前高={prior_high:.6f} " + f"(允许下方{near_high_below*100:.1f}%/上方{near_high_above*100:.1f}%)", + trend_4h, + ) + if flush_ok: + reasons.append(f"1H进行中上冲{flush['flush_up_pct']*100:.2f}%") + if vol_ok: + reasons.append(f"{vol_iv}涨{(vol_change or 0)*100:.2f}%") + reasons.append(f"贴近前高{prior_high:.6f}") + reasons.append(f"4H向下") + struct_key, struct_val = "prior_structure_high", prior_high + + strength = int(_cfg("VP_SIGNAL_STRENGTH", 8) or 8) + strength = max(1, min(10, strength)) + metrics = _pullback_range_metrics(bars_1h, price_f) + + meta = { + "entry_mode": "instant_flush", + "trend_1h": trend_1h, + "prior_structure_low": prior_low, + "prior_structure_high": prior_high, + "forming_1h_open": flush.get("open"), + "forming_1h_flush_down_pct": round(flush["flush_down_pct"] * 100, 4), + "forming_1h_flush_up_pct": round(flush["flush_up_pct"] * 100, 4), + "vol_interval": vol_iv, + "vol_change_pct": round((vol_change or 0) * 100, 4), + struct_key: struct_val, + "recent_low": prior_low, + "recent_high": prior_high, + } + if metrics: + meta["range_pos"] = round(metrics[2], 4) + + return { + "should_trade": True, + "direction": direction, + "reason": "VP即时波动: " + ", ".join(reasons), + "strength": strength, + "trend_4h": t4, + "trend_1h": trend_1h, + "strategy_type": "volatility_pullback", + "volatility_pullback": meta, + } + + +async def _evaluate_classic_align( + client, + symbol: str, + symbol_info: Dict, + trend_4h: Optional[str], + price_f: float, + beta_checker: Optional[BetaChecker], +) -> Dict[str, Any]: + """原 4H+1H 同向 + 区间位置 + 15m 波动模式。""" + trend_1h = judge_trend_1h(price_f, symbol_info.get("ema20"), symbol_info.get("ema50")) + t4 = (trend_4h or "neutral").strip().lower() + t1 = trend_1h + + require_align = bool(_cfg("VP_REQUIRE_1H_4H_ALIGN", True)) + if require_align: + if t4 not in ("up", "down") or t1 not in ("up", "down"): + return _fail(f"4H/1H 趋势未明确(4H={t4}, 1H={t1})", trend_4h) + if t4 != t1: + return _fail(f"4H与1H方向不一致(4H={t4}, 1H={t1})", trend_4h) + direction = "BUY" if t4 == "up" else "SELL" + else: + if t4 == "up": + direction = "BUY" + elif t4 == "down": + direction = "SELL" + else: + return _fail(f"4H 趋势中性,跳过(4H={t4})", trend_4h) + + range_iv = str(_cfg("VP_RANGE_INTERVAL", "1h") or "1h") + lookback = max(5, int(_cfg("VP_RANGE_LOOKBACK_BARS", 24) or 24)) + min_bars = max(3, int(_cfg("VP_RANGE_MIN_BARS", 5) or 5)) + max_long_pos = float(_cfg("VP_MAX_LONG_IN_RANGE", 0.42) or 0.42) + min_short_pos = float(_cfg("VP_MIN_SHORT_IN_RANGE", 0.58) or 0.58) + + bars_1h = await _fetch_klines(client, symbol, range_iv, lookback) + metrics = _pullback_range_metrics(bars_1h, price_f) + if metrics is None or len(bars_1h) < min_bars: + return _fail(f"1H 区间数据不足({len(bars_1h)}<{min_bars})", trend_4h) + recent_low, recent_high, pos = metrics + + vol_iv = str(_cfg("VP_VOL_INTERVAL", "15m") or "15m") + vol_bars = max(2, int(_cfg("VP_VOL_LOOKBACK_BARS", 5) or 5)) + long_min_drop = float(_cfg("VP_LONG_MIN_DROP_PCT", 0.008) or 0.008) + short_min_rise = float(_cfg("VP_SHORT_MIN_RISE_PCT", 0.008) or 0.008) + + vol_change = await _period_change(client, symbol, vol_iv, vol_bars) + if vol_change is None: + return _fail(f"{vol_iv} 波动数据不可用", trend_4h) + + reasons: List[str] = [] + if direction == "BUY": + if pos > max_long_pos: + return _fail( + f"1H 区间位置={pos:.2f} > 做多上限{max_long_pos:.2f}", + trend_4h, + ) + if vol_change > -long_min_drop: + return _fail( + f"{vol_iv}×{vol_bars} 跌幅不足:{vol_change*100:.2f}%", + trend_4h, + ) + if beta_checker is not None: + try: + beta = await beta_checker() + if beta.get("should_block_buy"): + return _fail(f"大盘共振过滤:{beta.get('reason', '禁多')}", trend_4h) + except Exception as e: + logger.debug(f"{symbol} Beta 检查异常(放行): {e}") + reasons.extend([f"4H/1H同向多", f"1H区间pos={pos:.2f}", f"{vol_iv}跌{vol_change*100:.2f}%"]) + struct_key, struct_val = "recent_low", recent_low + else: + if pos < min_short_pos: + return _fail(f"1H 区间位置={pos:.2f} < 做空下限{min_short_pos:.2f}", trend_4h) + if vol_change < short_min_rise: + return _fail(f"{vol_iv}×{vol_bars} 涨幅不足:{vol_change*100:.2f}%", trend_4h) + reasons.extend([f"4H/1H同向空", f"1H区间pos={pos:.2f}", f"{vol_iv}涨{vol_change*100:.2f}%"]) + struct_key, struct_val = "recent_high", recent_high + + strength = max(1, min(10, int(_cfg("VP_SIGNAL_STRENGTH", 8) or 8))) + meta = { + "entry_mode": "classic_align", + "trend_1h": t1, + "range_interval": range_iv, + "range_pos": round(pos, 4), + "recent_low": recent_low, + "recent_high": recent_high, + "vol_interval": vol_iv, + "vol_change_pct": round(vol_change * 100, 4), + struct_key: struct_val, + } + + return { + "should_trade": True, + "direction": direction, + "reason": "VP波动回调: " + ", ".join(reasons), + "strength": strength, + "trend_4h": t4, + "trend_1h": t1, + "strategy_type": "volatility_pullback", + "volatility_pullback": meta, + } + + +async def evaluate_volatility_pullback( + client, + symbol: str, + symbol_info: Dict, + trend_4h: Optional[str], + beta_checker: Optional[BetaChecker] = None, +) -> Dict[str, Any]: + if not bool(_cfg("VOLATILITY_PULLBACK_ENABLED", False)): + return _fail("波动回调策略未启用", trend_4h) + + price_f = await _resolve_live_price(client, symbol, symbol_info) + if not price_f or price_f <= 0: + return _fail("无法获取有效价格", trend_4h) + + if bool(_cfg("VP_INSTANT_FLUSH_ENABLED", True)): + return await _evaluate_instant_flush( + client, symbol, symbol_info, trend_4h, price_f, beta_checker + ) + return await _evaluate_classic_align( + client, symbol, symbol_info, trend_4h, price_f, beta_checker + )