368 lines
15 KiB
Python
368 lines
15 KiB
Python
"""VCP (Volatility Contraction Pattern) 精细化检测 — 多空双向。
|
||
|
||
做多 VCP:
|
||
- 价格从高点回调形成多次 swing low,每次回调幅度递减
|
||
- 同时成交量逐步萎缩到地量
|
||
- 最终一根放量 K 线突破整个收缩区间上沿
|
||
- 止损放在最后一次收缩低点,天然 RR 3:1+
|
||
|
||
做空 VCP(顶部分配 / Distribution):
|
||
- 价格从低点反弹形成多次 swing high,每次反弹幅度递减
|
||
- 成交量在反弹时递减(买盘衰竭)
|
||
- 最终一根放量阴线跌破收缩区间下沿
|
||
- 止损放在最后一次反弹高点
|
||
|
||
核心参数:
|
||
- 至少 2 次收缩(3 次更佳)
|
||
- 每次收缩幅度 < 前一次的 75%
|
||
- 最后一次收缩的成交量 < 前一次的 80%
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Optional
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
from app.config.config_loader import _get_section as _get_cfg_section
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Configuration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _vcp_config() -> dict:
|
||
try:
|
||
cfg = _get_cfg_section("vcp") or {}
|
||
except Exception:
|
||
cfg = {}
|
||
return {
|
||
"min_contractions": int(cfg.get("min_contractions", 2)),
|
||
"max_contractions": int(cfg.get("max_contractions", 5)),
|
||
"contraction_decay_max": float(cfg.get("contraction_decay_max", 0.75)),
|
||
"volume_decay_max": float(cfg.get("volume_decay_max", 0.85)),
|
||
"min_first_swing_pct": float(cfg.get("min_first_swing_pct", 5.0)),
|
||
"breakout_vol_ratio_min": float(cfg.get("breakout_vol_ratio_min", 2.0)),
|
||
"lookback_bars": int(cfg.get("lookback_bars", 60)),
|
||
"swing_window": int(cfg.get("swing_window", 3)),
|
||
# Factor weights
|
||
"weight_vcp_bull": float(cfg.get("weight_vcp_bull", 5.0)),
|
||
"weight_vcp_bear": float(cfg.get("weight_vcp_bear", 5.0)),
|
||
"weight_vcp_forming": float(cfg.get("weight_vcp_forming", 3.0)),
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Swing detection (simplified for VCP — needs clear pivots)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _find_swing_highs(df: pd.DataFrame, window: int = 3) -> list[dict]:
|
||
"""Find swing highs: local maxima with `window` bars on each side."""
|
||
highs = df["high"].values.astype(float)
|
||
n = len(highs)
|
||
swings = []
|
||
for i in range(window, n - window):
|
||
if all(highs[i] >= highs[i - j] for j in range(1, window + 1)) and \
|
||
all(highs[i] >= highs[i + j] for j in range(1, window + 1)):
|
||
swings.append({"index": i, "price": float(highs[i])})
|
||
return swings
|
||
|
||
|
||
def _find_swing_lows(df: pd.DataFrame, window: int = 3) -> list[dict]:
|
||
"""Find swing lows: local minima with `window` bars on each side."""
|
||
lows = df["low"].values.astype(float)
|
||
n = len(lows)
|
||
swings = []
|
||
for i in range(window, n - window):
|
||
if all(lows[i] <= lows[i - j] for j in range(1, window + 1)) and \
|
||
all(lows[i] <= lows[i + j] for j in range(1, window + 1)):
|
||
swings.append({"index": i, "price": float(lows[i])})
|
||
return swings
|
||
|
||
|
||
def _avg_volume_around(df: pd.DataFrame, index: int, window: int = 3) -> float:
|
||
"""Average volume around a swing point."""
|
||
start = max(0, index - window)
|
||
end = min(len(df), index + window + 1)
|
||
return float(df["volume"].iloc[start:end].mean())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# VCP Detection — Bullish (做多)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def detect_vcp_bull(df: pd.DataFrame) -> dict:
|
||
"""Detect bullish VCP: swing lows with decreasing depth + volume contraction.
|
||
|
||
Looks for a series of pullbacks from a resistance level where each pullback
|
||
is shallower than the last, indicating sellers are exhausting.
|
||
|
||
Args:
|
||
df: 4H or 1D kline DataFrame (recommend 60+ bars)
|
||
|
||
Returns:
|
||
{
|
||
"detected": bool,
|
||
"forming": bool, # pattern forming but not yet broken out
|
||
"contractions": int, # number of valid contractions
|
||
"depths_pct": list, # each contraction depth as %
|
||
"volume_ratios": list, # volume at each contraction relative to first
|
||
"pivot_high": float, # resistance level (breakout target)
|
||
"last_low": float, # last contraction low (stop loss level)
|
||
"breakout": bool, # has price broken above pivot_high with volume
|
||
"rr_estimate": float, # estimated risk/reward if entering now
|
||
"score": float,
|
||
"signal": str,
|
||
}
|
||
"""
|
||
cfg = _vcp_config()
|
||
result = {"detected": False, "forming": False, "contractions": 0,
|
||
"depths_pct": [], "volume_ratios": [], "pivot_high": 0,
|
||
"last_low": 0, "breakout": False, "rr_estimate": 0, "score": 0, "signal": ""}
|
||
|
||
if df is None or len(df) < cfg["lookback_bars"]:
|
||
return result
|
||
|
||
recent = df.tail(cfg["lookback_bars"]).reset_index(drop=True)
|
||
swing_highs = _find_swing_highs(recent, cfg["swing_window"])
|
||
swing_lows = _find_swing_lows(recent, cfg["swing_window"])
|
||
|
||
if len(swing_highs) < 1 or len(swing_lows) < cfg["min_contractions"]:
|
||
return result
|
||
|
||
# Find the resistance level (highest swing high in the lookback)
|
||
pivot_high = max(s["price"] for s in swing_highs)
|
||
result["pivot_high"] = round(pivot_high, 6)
|
||
|
||
# Measure contraction depths: distance from pivot_high to each swing low
|
||
depths = []
|
||
volumes = []
|
||
for sl in swing_lows:
|
||
depth_pct = (pivot_high - sl["price"]) / pivot_high * 100 if pivot_high > 0 else 0
|
||
if depth_pct > 0:
|
||
vol = _avg_volume_around(recent, sl["index"], cfg["swing_window"])
|
||
depths.append({"depth_pct": depth_pct, "price": sl["price"], "index": sl["index"], "volume": vol})
|
||
|
||
if len(depths) < cfg["min_contractions"]:
|
||
return result
|
||
|
||
# Filter: keep only the last N contractions that show decay
|
||
# Start from the deepest (first major pullback) and check subsequent ones decay
|
||
depths.sort(key=lambda x: x["index"])
|
||
|
||
# Find the first significant pullback
|
||
valid_sequence = []
|
||
for d in depths:
|
||
if d["depth_pct"] >= cfg["min_first_swing_pct"]:
|
||
valid_sequence = [d]
|
||
break
|
||
|
||
if not valid_sequence:
|
||
return result
|
||
|
||
# Build contraction sequence: each subsequent must be shallower
|
||
for d in depths:
|
||
if d["index"] <= valid_sequence[-1]["index"]:
|
||
continue
|
||
if d["depth_pct"] < valid_sequence[-1]["depth_pct"]:
|
||
valid_sequence.append(d)
|
||
|
||
if len(valid_sequence) < cfg["min_contractions"]:
|
||
return result
|
||
|
||
# Verify decay ratios
|
||
depth_ratios = []
|
||
volume_ratios = []
|
||
first_vol = valid_sequence[0]["volume"]
|
||
|
||
for i in range(1, len(valid_sequence)):
|
||
ratio = valid_sequence[i]["depth_pct"] / valid_sequence[i - 1]["depth_pct"]
|
||
depth_ratios.append(ratio)
|
||
vol_ratio = valid_sequence[i]["volume"] / first_vol if first_vol > 0 else 1
|
||
volume_ratios.append(vol_ratio)
|
||
|
||
# Check all depth ratios are decaying
|
||
all_decaying = all(r <= cfg["contraction_decay_max"] for r in depth_ratios)
|
||
# Volume should also be contracting (at least the last one)
|
||
vol_contracting = volume_ratios[-1] <= cfg["volume_decay_max"] if volume_ratios else False
|
||
|
||
if not all_decaying:
|
||
return result
|
||
|
||
# Pattern is forming
|
||
last_low = valid_sequence[-1]["price"]
|
||
result["forming"] = True
|
||
result["contractions"] = len(valid_sequence)
|
||
result["depths_pct"] = [round(d["depth_pct"], 2) for d in valid_sequence]
|
||
result["volume_ratios"] = [round(v, 2) for v in volume_ratios]
|
||
result["last_low"] = round(last_low, 6)
|
||
|
||
# Check for breakout: current price above pivot_high with volume
|
||
current_close = float(recent["close"].iloc[-1])
|
||
current_vol = float(recent["volume"].iloc[-1])
|
||
avg_vol = float(recent["volume"].rolling(20).mean().iloc[-1]) if len(recent) >= 20 else float(recent["volume"].mean())
|
||
vol_breakout = current_vol / avg_vol if avg_vol > 0 else 0
|
||
|
||
if current_close > pivot_high and vol_breakout >= cfg["breakout_vol_ratio_min"]:
|
||
result["breakout"] = True
|
||
result["detected"] = True
|
||
# RR estimate: risk = entry to last_low, reward = depth of first contraction projected up
|
||
risk = current_close - last_low
|
||
reward = valid_sequence[0]["depth_pct"] / 100 * pivot_high # project first swing depth as target
|
||
result["rr_estimate"] = round(reward / risk, 2) if risk > 0 else 0
|
||
result["score"] = cfg["weight_vcp_bull"]
|
||
result["signal"] = f"VCP突破({len(valid_sequence)}次收缩, 量{vol_breakout:.1f}x, RR≈{result['rr_estimate']})"
|
||
elif current_close > last_low and current_close < pivot_high:
|
||
# Forming but not broken out yet — still valuable as watch signal
|
||
result["detected"] = True
|
||
risk = current_close - last_low
|
||
reward = pivot_high - current_close + (valid_sequence[0]["depth_pct"] / 100 * pivot_high * 0.5)
|
||
result["rr_estimate"] = round(reward / risk, 2) if risk > 0 else 0
|
||
result["score"] = cfg["weight_vcp_forming"]
|
||
result["signal"] = f"VCP蓄力中({len(valid_sequence)}次收缩{'量缩' if vol_contracting else ''}, 待突破${pivot_high:.4f})"
|
||
|
||
return result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# VCP Detection — Bearish / Distribution (做空)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def detect_vcp_bear(df: pd.DataFrame) -> dict:
|
||
"""Detect bearish VCP (distribution): swing highs with decreasing bounce height.
|
||
|
||
Looks for a series of rallies from a support level where each rally is
|
||
weaker than the last, indicating buyers are exhausting.
|
||
|
||
Args:
|
||
df: 4H or 1D kline DataFrame (recommend 60+ bars)
|
||
|
||
Returns:
|
||
Same structure as detect_vcp_bull but for short side.
|
||
"""
|
||
cfg = _vcp_config()
|
||
result = {"detected": False, "forming": False, "contractions": 0,
|
||
"depths_pct": [], "volume_ratios": [], "pivot_low": 0,
|
||
"last_high": 0, "breakdown": False, "rr_estimate": 0, "score": 0, "signal": ""}
|
||
|
||
if df is None or len(df) < cfg["lookback_bars"]:
|
||
return result
|
||
|
||
recent = df.tail(cfg["lookback_bars"]).reset_index(drop=True)
|
||
swing_highs = _find_swing_highs(recent, cfg["swing_window"])
|
||
swing_lows = _find_swing_lows(recent, cfg["swing_window"])
|
||
|
||
if len(swing_lows) < 1 or len(swing_highs) < cfg["min_contractions"]:
|
||
return result
|
||
|
||
# Find the support level (lowest swing low in the lookback)
|
||
pivot_low = min(s["price"] for s in swing_lows)
|
||
result["pivot_low"] = round(pivot_low, 6)
|
||
|
||
# Measure bounce heights: distance from pivot_low to each swing high
|
||
bounces = []
|
||
for sh in swing_highs:
|
||
bounce_pct = (sh["price"] - pivot_low) / pivot_low * 100 if pivot_low > 0 else 0
|
||
if bounce_pct > 0:
|
||
vol = _avg_volume_around(recent, sh["index"], cfg["swing_window"])
|
||
bounces.append({"bounce_pct": bounce_pct, "price": sh["price"], "index": sh["index"], "volume": vol})
|
||
|
||
if len(bounces) < cfg["min_contractions"]:
|
||
return result
|
||
|
||
bounces.sort(key=lambda x: x["index"])
|
||
|
||
# Find first significant bounce
|
||
valid_sequence = []
|
||
for b in bounces:
|
||
if b["bounce_pct"] >= cfg["min_first_swing_pct"]:
|
||
valid_sequence = [b]
|
||
break
|
||
|
||
if not valid_sequence:
|
||
return result
|
||
|
||
# Build sequence: each subsequent bounce must be weaker
|
||
for b in bounces:
|
||
if b["index"] <= valid_sequence[-1]["index"]:
|
||
continue
|
||
if b["bounce_pct"] < valid_sequence[-1]["bounce_pct"]:
|
||
valid_sequence.append(b)
|
||
|
||
if len(valid_sequence) < cfg["min_contractions"]:
|
||
return result
|
||
|
||
# Verify decay
|
||
bounce_ratios = []
|
||
volume_ratios = []
|
||
first_vol = valid_sequence[0]["volume"]
|
||
|
||
for i in range(1, len(valid_sequence)):
|
||
ratio = valid_sequence[i]["bounce_pct"] / valid_sequence[i - 1]["bounce_pct"]
|
||
bounce_ratios.append(ratio)
|
||
vol_ratio = valid_sequence[i]["volume"] / first_vol if first_vol > 0 else 1
|
||
volume_ratios.append(vol_ratio)
|
||
|
||
all_decaying = all(r <= cfg["contraction_decay_max"] for r in bounce_ratios)
|
||
vol_contracting = volume_ratios[-1] <= cfg["volume_decay_max"] if volume_ratios else False
|
||
|
||
if not all_decaying:
|
||
return result
|
||
|
||
last_high = valid_sequence[-1]["price"]
|
||
result["forming"] = True
|
||
result["contractions"] = len(valid_sequence)
|
||
result["depths_pct"] = [round(b["bounce_pct"], 2) for b in valid_sequence]
|
||
result["volume_ratios"] = [round(v, 2) for v in volume_ratios]
|
||
result["last_high"] = round(last_high, 6)
|
||
|
||
# Check for breakdown
|
||
current_close = float(recent["close"].iloc[-1])
|
||
current_vol = float(recent["volume"].iloc[-1])
|
||
avg_vol = float(recent["volume"].rolling(20).mean().iloc[-1]) if len(recent) >= 20 else float(recent["volume"].mean())
|
||
vol_breakout = current_vol / avg_vol if avg_vol > 0 else 0
|
||
|
||
if current_close < pivot_low and vol_breakout >= cfg["breakout_vol_ratio_min"]:
|
||
result["breakdown"] = True
|
||
result["detected"] = True
|
||
risk = last_high - current_close
|
||
reward = valid_sequence[0]["bounce_pct"] / 100 * pivot_low
|
||
result["rr_estimate"] = round(reward / risk, 2) if risk > 0 else 0
|
||
result["score"] = cfg["weight_vcp_bear"]
|
||
result["signal"] = f"顶部分配破位({len(valid_sequence)}次反弹递减, 量{vol_breakout:.1f}x, RR≈{result['rr_estimate']})"
|
||
elif current_close < last_high and current_close > pivot_low:
|
||
result["detected"] = True
|
||
risk = last_high - current_close
|
||
reward = current_close - pivot_low + (valid_sequence[0]["bounce_pct"] / 100 * pivot_low * 0.5)
|
||
result["rr_estimate"] = round(reward / risk, 2) if risk > 0 else 0
|
||
result["score"] = cfg["weight_vcp_forming"]
|
||
result["signal"] = f"顶部分配蓄力({len(valid_sequence)}次反弹递减{'量缩' if vol_contracting else ''}, 待破位${pivot_low:.4f})"
|
||
|
||
return result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Unified interface
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def detect_vcp(df: pd.DataFrame, direction: str = "both") -> dict:
|
||
"""Detect VCP pattern for given direction.
|
||
|
||
Args:
|
||
df: 4H or 1D kline DataFrame
|
||
direction: "long", "short", or "both"
|
||
|
||
Returns:
|
||
{"bull": {...}, "bear": {...}} or single direction result
|
||
"""
|
||
if direction == "long":
|
||
return detect_vcp_bull(df)
|
||
elif direction == "short":
|
||
return detect_vcp_bear(df)
|
||
else:
|
||
return {
|
||
"bull": detect_vcp_bull(df),
|
||
"bear": detect_vcp_bear(df),
|
||
}
|