480 lines
17 KiB
Python
480 lines
17 KiB
Python
"""
|
||
波动回调入场(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
|
||
)
|