""" 波动回调入场(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() trend_1h = judge_trend_1h( price_f, symbol_info.get("ema20"), symbol_info.get("ema50") ) t1 = trend_1h allow_neutral_4h = bool(_cfg("VP_ALLOW_4H_NEUTRAL", False)) if t4 in ("up", "down"): direction = "BUY" if t4 == "up" else "SELL" elif allow_neutral_4h and t1 in ("up", "down"): direction = "BUY" if t1 == "up" else "SELL" else: return _fail( f"4H 趋势未明确(4H={t4})" + (f",1H={t1}" if allow_neutral_4h else ",即时模式需 up/down"), trend_4h, ) 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) 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}") if t4 == "up": reasons.append("4H向上") else: reasons.append(f"4H中性+1H向上") 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}") if t4 == "down": reasons.append("4H向下") else: reasons.append(f"4H中性+1H向下") 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 )