649 lines
19 KiB
Python
649 lines
19 KiB
Python
"""趋势突破入场信号分类 + 供需分析 + 趋势评分
|
||
|
||
三种入场信号类型:
|
||
- 突破型 (breakout): 放量突破 20 日阻力位
|
||
- 回踩型 (pullback): 上升趋势中缩量回踩均线支撑
|
||
- 启动型 (launch): 高位缩量整理后即将变盘
|
||
"""
|
||
|
||
import logging
|
||
from enum import Enum
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
from app.config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class EntrySignal(str, Enum):
|
||
BREAKOUT = "breakout"
|
||
PULLBACK = "pullback"
|
||
LAUNCH = "launch"
|
||
NONE = "none"
|
||
|
||
|
||
# ── 入场信号检测 ──
|
||
|
||
|
||
def classify_entry_signal(df: pd.DataFrame) -> dict:
|
||
"""分类入场信号类型
|
||
|
||
优先级: breakout > pullback > launch
|
||
|
||
Returns:
|
||
{
|
||
"signal_type": EntrySignal,
|
||
"signal_score": float (0-100),
|
||
"details": dict, # 信号相关详情
|
||
}
|
||
"""
|
||
if df is None or len(df) < 30:
|
||
return {"signal_type": EntrySignal.NONE, "signal_score": 0, "details": {}}
|
||
|
||
# 优先检测突破型(最符合 1-5 天操作窗口)
|
||
result = detect_breakout(df)
|
||
if result:
|
||
result["signal_score"] = _score_breakout(df, result)
|
||
return result
|
||
|
||
# 回踩型(风险收益比最好)
|
||
result = detect_pullback(df)
|
||
if result:
|
||
result["signal_score"] = _score_pullback(df, result)
|
||
return result
|
||
|
||
# 启动型(需要耐心等待)
|
||
result = detect_launch(df)
|
||
if result:
|
||
result["signal_score"] = _score_launch(df, result)
|
||
return result
|
||
|
||
return {"signal_type": EntrySignal.NONE, "signal_score": 0, "details": {}}
|
||
|
||
|
||
def detect_breakout(df: pd.DataFrame) -> dict | None:
|
||
"""突破型检测
|
||
|
||
条件(全部满足):
|
||
1. 价格突破 20 日阻力位
|
||
2. 量能放大 (vol > vol_ma5 * 1.5)
|
||
3. 短期均线多头 (MA5 > MA10 > MA20)
|
||
4. 收盘偏强 (close 在当日区间上半部分)
|
||
"""
|
||
last = df.iloc[-1]
|
||
|
||
# 必需指标列
|
||
required = ["close", "high", "low", "vol", "ma5", "ma10", "ma20", "vol_ma5"]
|
||
if any(c not in df.columns for c in required):
|
||
return None
|
||
if pd.isna(last["vol_ma5"]) or last["vol_ma5"] == 0:
|
||
return None
|
||
|
||
# 1. 突破 20 日阻力(允许距阻力位 1% 以内,贴近即视为突破前兆)
|
||
if len(df) < 22:
|
||
return None
|
||
resist_20d = df["high"].iloc[-21:-1].max()
|
||
if last["close"] < resist_20d * 0.99:
|
||
return None
|
||
|
||
# 2. 量能放大
|
||
min_vol_ratio = getattr(settings, "breakout_min_volume_ratio", 1.2)
|
||
if last["vol"] <= last["vol_ma5"] * min_vol_ratio:
|
||
return None
|
||
|
||
# 3. 短期均线偏多(MA5 > MA20 即可,不要求三线完美排列)
|
||
if not (last["ma5"] > last["ma20"]):
|
||
return None
|
||
|
||
# 4. 收盘偏强(不低于当日中点)
|
||
day_range = last["high"] - last["low"]
|
||
if day_range > 0 and (last["close"] - last["low"]) / day_range < 0.5:
|
||
return None
|
||
|
||
breakout_pct = (last["close"] - resist_20d) / resist_20d * 100
|
||
|
||
return {
|
||
"signal_type": EntrySignal.BREAKOUT,
|
||
"details": {
|
||
"resist_level": round(resist_20d, 2),
|
||
"breakout_pct": round(breakout_pct, 2),
|
||
"volume_ratio": round(last["vol"] / last["vol_ma5"], 2),
|
||
},
|
||
}
|
||
|
||
|
||
def detect_pullback(df: pd.DataFrame) -> dict | None:
|
||
"""回踩型检测
|
||
|
||
条件(全部满足):
|
||
1. MA20 > MA60 — 中长期上升趋势
|
||
2. 近 5 日内有 MA5 > MA20 — 近期处于上升趋势
|
||
3. 价格靠近 MA10 或 MA20 (<2%)
|
||
4. 缩量回调 (近 3 日均量 < 前 5 日均量 * 0.7)
|
||
5. RSI 40-65
|
||
"""
|
||
last = df.iloc[-1]
|
||
|
||
required = ["close", "vol", "ma5", "ma10", "ma20", "ma60", "rsi14"]
|
||
if any(c not in df.columns for c in required):
|
||
return None
|
||
if pd.isna(last["ma60"]) or pd.isna(last["rsi14"]):
|
||
return None
|
||
|
||
# 1. 中长期上升趋势
|
||
if not (last["ma20"] > last["ma60"]):
|
||
return None
|
||
|
||
# 2. 近 5 日内有 MA5 > MA20
|
||
recent_5 = df.tail(5)
|
||
has_uptrend = any(
|
||
row["ma5"] > row["ma20"]
|
||
for _, row in recent_5.iterrows()
|
||
if not pd.isna(row["ma5"]) and not pd.isna(row["ma20"])
|
||
)
|
||
if not has_uptrend:
|
||
return None
|
||
|
||
# 3. 价格靠近 MA10 或 MA20(放宽至 4%)
|
||
near_ma10 = (
|
||
not pd.isna(last["ma10"])
|
||
and last["ma10"] > 0
|
||
and abs(last["close"] - last["ma10"]) / last["ma10"] < 0.04
|
||
)
|
||
near_ma20 = (
|
||
not pd.isna(last["ma20"])
|
||
and last["ma20"] > 0
|
||
and abs(last["close"] - last["ma20"]) / last["ma20"] < 0.04
|
||
)
|
||
if not (near_ma10 or near_ma20):
|
||
return None
|
||
|
||
# 4. 缩量回调(放宽至 0.85)
|
||
if len(df) < 8:
|
||
return None
|
||
recent_3_vol = df["vol"].tail(3).mean()
|
||
prev_5_vol = df["vol"].iloc[-8:-3].mean()
|
||
if prev_5_vol == 0:
|
||
return None
|
||
shrink_ratio = recent_3_vol / prev_5_vol
|
||
max_shrink = getattr(settings, "pullback_max_shrink_ratio", 0.85)
|
||
if shrink_ratio >= max_shrink:
|
||
return None
|
||
|
||
# 5. RSI 范围(放宽至 35-70)
|
||
if not (35 <= last["rsi14"] <= 70):
|
||
return None
|
||
|
||
support_ma = "MA10" if near_ma10 else "MA20"
|
||
support_price = last["ma10"] if near_ma10 else last["ma20"]
|
||
|
||
return {
|
||
"signal_type": EntrySignal.PULLBACK,
|
||
"details": {
|
||
"support_ma": support_ma,
|
||
"support_price": round(support_price, 2),
|
||
"volume_shrink_ratio": round(shrink_ratio, 2),
|
||
},
|
||
}
|
||
|
||
|
||
def detect_launch(df: pd.DataFrame) -> dict | None:
|
||
"""启动型检测(高位缩量整理后即将变盘)
|
||
|
||
条件(全部满足):
|
||
1. close > MA60 且距 20 日高点 < 5%
|
||
2. 近 10 日振幅 < 5%,每日涨跌幅 < 3%
|
||
3. 近 5 日均量 < 前 5 日均量 * 0.6
|
||
4. MA5 > MA20(多头格局未破)
|
||
5. 布林带宽收窄
|
||
6. DIF > 0
|
||
"""
|
||
last = df.iloc[-1]
|
||
|
||
required = ["close", "high", "vol", "pct_chg", "ma5", "ma20", "ma60",
|
||
"dif", "boll_bw"]
|
||
if any(c not in df.columns for c in required):
|
||
return None
|
||
if pd.isna(last["ma60"]) or pd.isna(last["dif"]) or pd.isna(last["boll_bw"]):
|
||
return None
|
||
|
||
# 1. 高位
|
||
if last["close"] < last["ma60"]:
|
||
return None
|
||
high_20d = df["high"].tail(20).max()
|
||
if (high_20d - last["close"]) / high_20d > 0.05:
|
||
return None
|
||
|
||
# 2. 窄幅整理(放宽振幅至 8%,每日涨跌幅至 4%)
|
||
if len(df) < 10:
|
||
return None
|
||
recent = df.tail(10)
|
||
close_range = (recent["close"].max() - recent["close"].min()) / recent["close"].min()
|
||
max_range = getattr(settings, "consolidation_max_range_pct", 8.0) / 100
|
||
if close_range > max_range:
|
||
return None
|
||
if any(abs(row["pct_chg"]) > 4 for _, row in recent.iterrows() if not pd.isna(row.get("pct_chg"))):
|
||
return None
|
||
|
||
# 3. 缩量(放宽至 0.75)
|
||
if len(df) < 15:
|
||
return None
|
||
recent_5_vol = df["vol"].tail(5).mean()
|
||
earlier_5_vol = df["vol"].iloc[-15:-10].mean()
|
||
if earlier_5_vol == 0:
|
||
return None
|
||
vol_ratio = recent_5_vol / earlier_5_vol
|
||
if vol_ratio >= 0.75:
|
||
return None
|
||
|
||
# 4. 多头格局未破
|
||
if last["ma5"] <= last["ma20"]:
|
||
return None
|
||
|
||
# 5-6. 布林收窄 + MACD 零轴上方(软条件,不满足不淘汰)
|
||
boll_narrowing = False
|
||
dif_positive = False
|
||
|
||
if len(df) >= 20 and not pd.isna(last["boll_bw"]):
|
||
bw_10d_ago = df.iloc[-10]["boll_bw"]
|
||
if not pd.isna(bw_10d_ago) and last["boll_bw"] < bw_10d_ago:
|
||
boll_narrowing = True
|
||
|
||
if not pd.isna(last["dif"]) and last["dif"] > 0:
|
||
dif_positive = True
|
||
|
||
return {
|
||
"signal_type": EntrySignal.LAUNCH,
|
||
"details": {
|
||
"consolidation_days": 10,
|
||
"volume_shrink_ratio": round(vol_ratio, 2),
|
||
"price_range_pct": round(close_range * 100, 2),
|
||
"resist_level": round(high_20d, 2),
|
||
"boll_narrowing": boll_narrowing,
|
||
"dif_positive": dif_positive,
|
||
},
|
||
}
|
||
|
||
|
||
# ── 信号质量评分 ──
|
||
|
||
|
||
def _score_breakout(df: pd.DataFrame, signal: dict) -> float:
|
||
"""突破型信号质量评分 (0-100)"""
|
||
last = df.iloc[-1]
|
||
score = 0
|
||
|
||
# 突破力度 (25)
|
||
breakout_pct = signal["details"].get("breakout_pct", 0)
|
||
if breakout_pct > 3:
|
||
score += 25
|
||
elif breakout_pct > 1.5:
|
||
score += 18
|
||
elif breakout_pct > 0.5:
|
||
score += 10
|
||
|
||
# 量能放大程度 (25)
|
||
vol_ratio = signal["details"].get("volume_ratio", 1)
|
||
if vol_ratio > 2.5:
|
||
score += 25
|
||
elif vol_ratio > 2.0:
|
||
score += 20
|
||
elif vol_ratio > 1.5:
|
||
score += 12
|
||
|
||
# 均线完整性 (25)
|
||
if all(c in df.columns for c in ["ma60"]):
|
||
if not pd.isna(last["ma60"]) and last["ma5"] > last["ma10"] > last["ma20"] > last["ma60"]:
|
||
score += 25
|
||
elif last["ma5"] > last["ma10"] > last["ma20"]:
|
||
score += 15
|
||
else:
|
||
score += 10
|
||
|
||
# MACD 状态 (15)
|
||
if all(c in df.columns for c in ["dif", "dea"]):
|
||
if not pd.isna(last["dif"]) and last["dif"] > last["dea"] and last["dif"] > 0:
|
||
score += 15
|
||
elif not pd.isna(last["dif"]) and last["dif"] > last["dea"]:
|
||
score += 8
|
||
|
||
# RSI 区间 (10)
|
||
if "rsi14" in df.columns and not pd.isna(last["rsi14"]):
|
||
if 50 <= last["rsi14"] <= 75:
|
||
score += 10
|
||
elif 45 <= last["rsi14"] <= 80:
|
||
score += 5
|
||
|
||
return min(score, 100)
|
||
|
||
|
||
def _score_pullback(df: pd.DataFrame, signal: dict) -> float:
|
||
"""回踩型信号质量评分 (0-100)"""
|
||
last = df.iloc[-1]
|
||
score = 0
|
||
|
||
# 缩量程度 (30)
|
||
shrink = signal["details"].get("volume_shrink_ratio", 1)
|
||
if shrink < 0.4:
|
||
score += 30
|
||
elif shrink < 0.5:
|
||
score += 22
|
||
elif shrink < 0.6:
|
||
score += 15
|
||
|
||
# 支撑有效性 (25)
|
||
support_ma = signal["details"].get("support_ma", "")
|
||
if support_ma == "MA20":
|
||
score += 25 # MA20 支撑更强
|
||
elif support_ma == "MA10":
|
||
score += 15
|
||
|
||
# 均线多头 (25)
|
||
if all(c in df.columns for c in ["ma60"]):
|
||
if not pd.isna(last["ma60"]) and last["ma5"] > last["ma10"] > last["ma20"] > last["ma60"]:
|
||
score += 25
|
||
elif last["ma5"] > last["ma10"] > last["ma20"]:
|
||
score += 15
|
||
|
||
# MACD 状态 (10)
|
||
if all(c in df.columns for c in ["dif", "dea"]):
|
||
if not pd.isna(last["dif"]) and last["dif"] > 0:
|
||
score += 10
|
||
elif not pd.isna(last["dif"]) and last["dif"] > last["dea"]:
|
||
score += 5
|
||
|
||
# RSI (10)
|
||
if "rsi14" in df.columns and not pd.isna(last["rsi14"]):
|
||
if 45 <= last["rsi14"] <= 60:
|
||
score += 10
|
||
elif 40 <= last["rsi14"] <= 65:
|
||
score += 5
|
||
|
||
return min(score, 100)
|
||
|
||
|
||
def _score_launch(df: pd.DataFrame, signal: dict) -> float:
|
||
"""启动型信号质量评分 (0-100)"""
|
||
last = df.iloc[-1]
|
||
score = 0
|
||
details = signal.get("details", {})
|
||
|
||
# 整理充分度 (25) — 振幅越小越好
|
||
range_pct = details.get("price_range_pct", 10)
|
||
if range_pct < 2:
|
||
score += 25
|
||
elif range_pct < 4:
|
||
score += 18
|
||
elif range_pct < 8:
|
||
score += 10
|
||
|
||
# 缩量程度 (25)
|
||
shrink = details.get("volume_shrink_ratio", 1)
|
||
if shrink < 0.3:
|
||
score += 25
|
||
elif shrink < 0.5:
|
||
score += 18
|
||
elif shrink < 0.75:
|
||
score += 10
|
||
|
||
# 均线保持 (20)
|
||
if all(c in df.columns for c in ["ma60"]):
|
||
if not pd.isna(last["ma60"]) and last["ma5"] > last["ma20"] > last["ma60"]:
|
||
score += 20
|
||
elif last["ma5"] > last["ma20"]:
|
||
score += 10
|
||
|
||
# 布林收窄 (15) — 软条件,满足加分
|
||
if details.get("boll_narrowing"):
|
||
if "boll_bw" in df.columns and len(df) >= 20:
|
||
bw_now = last["boll_bw"]
|
||
bw_10d = df.iloc[-10]["boll_bw"]
|
||
if not pd.isna(bw_10d) and bw_now > 0:
|
||
narrowing = (bw_10d - bw_now) / bw_10d * 100
|
||
if narrowing > 30:
|
||
score += 15
|
||
elif narrowing > 15:
|
||
score += 10
|
||
else:
|
||
score += 5
|
||
|
||
# MACD (15) — 软条件,满足加分
|
||
if details.get("dif_positive"):
|
||
score += 15
|
||
elif all(c in df.columns for c in ["dif"]) and not pd.isna(last.get("dif")):
|
||
score += 5
|
||
|
||
return min(score, 100)
|
||
|
||
|
||
# ── 趋势 & 时机评分 ──
|
||
|
||
|
||
def score_trend_timing(df: pd.DataFrame, entry_signal: dict) -> float:
|
||
"""趋势&时机评分 (0-100)
|
||
|
||
维度:
|
||
- MA 排列 (25)
|
||
- MA20 方向 (10)
|
||
- 更高高点形态 (15)
|
||
- 入场信号质量 (50)
|
||
"""
|
||
last = df.iloc[-1]
|
||
score = 0
|
||
|
||
# MA 排列 (25)
|
||
ma_cols = [c for c in ["ma5", "ma10", "ma20", "ma60"] if c in df.columns]
|
||
if len(ma_cols) >= 4 and not any(pd.isna(last[c]) for c in ma_cols):
|
||
if last["ma5"] > last["ma10"] > last["ma20"] > last["ma60"]:
|
||
score += 25
|
||
elif last["ma5"] > last["ma10"] > last["ma20"]:
|
||
score += 18
|
||
elif last["ma5"] > last["ma20"]:
|
||
score += 10
|
||
|
||
# MA20 方向 (10)
|
||
if "ma20" in df.columns and len(df) >= 3:
|
||
if not pd.isna(last["ma20"]) and not pd.isna(df.iloc[-3]["ma20"]):
|
||
if last["ma20"] > df.iloc[-3]["ma20"]:
|
||
score += 10
|
||
|
||
# 更高高点形态 (15)
|
||
if _check_higher_highs(df):
|
||
score += 15
|
||
|
||
# 入场信号质量 (50)
|
||
signal_score = entry_signal.get("signal_score", 0)
|
||
score += signal_score * 0.5
|
||
|
||
return min(score, 100)
|
||
|
||
|
||
# ── 供需分析评分 ──
|
||
|
||
|
||
def score_supply_demand(df: pd.DataFrame) -> float:
|
||
"""供需评分 (0-100)
|
||
|
||
维度:
|
||
- 上涨日 vs 下跌日量比 (50): 需求主导度
|
||
- 量能收缩/扩张 (30): 供需变化趋势
|
||
- 量价配合 (20): 上涨放量+下跌缩量
|
||
"""
|
||
score = 0
|
||
|
||
if len(df) < 10:
|
||
return 50.0
|
||
|
||
recent = df.tail(10)
|
||
last = df.iloc[-1]
|
||
|
||
# 上涨日 vs 下跌日量比 (50)
|
||
up_days = recent[recent["pct_chg"] > 0]
|
||
down_days = recent[recent["pct_chg"] <= 0]
|
||
|
||
if len(up_days) > 0 and len(down_days) > 0:
|
||
avg_up_vol = up_days["vol"].mean()
|
||
avg_down_vol = down_days["vol"].mean()
|
||
if avg_down_vol > 0:
|
||
ratio = avg_up_vol / avg_down_vol
|
||
if ratio > 2.0:
|
||
score += 50
|
||
elif ratio > 1.5:
|
||
score += 38
|
||
elif ratio > 1.2:
|
||
score += 25
|
||
elif ratio > 1.0:
|
||
score += 12
|
||
elif len(up_days) > 0:
|
||
score += 40 # 全部上涨日,需求强劲
|
||
|
||
# 量能收缩/扩张 (30)
|
||
vol_ma_col = "vol_ma10" if "vol_ma10" in df.columns else "vol_ma5"
|
||
if vol_ma_col in df.columns and not pd.isna(last[vol_ma_col]) and last[vol_ma_col] > 0:
|
||
recent_3_vol = recent["vol"].tail(3).mean()
|
||
shrink_ratio = recent_3_vol / last[vol_ma_col]
|
||
|
||
if shrink_ratio < 0.4:
|
||
score += 30 # 极度缩量,供应枯竭
|
||
elif shrink_ratio < 0.6:
|
||
score += 22
|
||
elif shrink_ratio < 0.8:
|
||
score += 12
|
||
elif shrink_ratio > 1.5:
|
||
score += 25 # 放量,需求进入
|
||
elif shrink_ratio > 1.2:
|
||
score += 15
|
||
|
||
# 量价配合 (20)
|
||
if _check_bullish_volume_divergence(df):
|
||
score += 20
|
||
|
||
return min(score, 100)
|
||
|
||
|
||
def analyze_volume_pattern(df: pd.DataFrame) -> dict:
|
||
"""量价模式分析
|
||
|
||
Returns:
|
||
{
|
||
"demand_supply_ratio": float, # 上涨日均量/下跌日均量
|
||
"volume_trend": str, # expanding/contracting/stable
|
||
"accumulation_score": float, # 0-100
|
||
}
|
||
"""
|
||
default = {"demand_supply_ratio": 1.0, "volume_trend": "stable", "accumulation_score": 0}
|
||
|
||
if len(df) < 10:
|
||
return default
|
||
|
||
recent = df.tail(10)
|
||
|
||
# 需求/供给比
|
||
up_days = recent[recent["pct_chg"] > 0]
|
||
down_days = recent[recent["pct_chg"] <= 0]
|
||
avg_up_vol = up_days["vol"].mean() if len(up_days) > 0 else 0
|
||
avg_down_vol = down_days["vol"].mean() if len(down_days) > 0 else 1
|
||
ratio = round(avg_up_vol / max(avg_down_vol, 1), 2)
|
||
|
||
# 量能趋势
|
||
if len(df) >= 10:
|
||
vol_5d_ago = df["vol"].iloc[-10:-5].mean()
|
||
vol_recent = df["vol"].tail(5).mean()
|
||
if vol_5d_ago > 0:
|
||
trend_ratio = vol_recent / vol_5d_ago
|
||
trend = "expanding" if trend_ratio > 1.2 else ("contracting" if trend_ratio < 0.8 else "stable")
|
||
else:
|
||
trend = "stable"
|
||
else:
|
||
trend = "stable"
|
||
|
||
# 累积评分
|
||
accumulation = 0
|
||
if ratio > 1.5:
|
||
accumulation += 40
|
||
if trend == "expanding" and df.iloc[-1]["pct_chg"] > 0:
|
||
accumulation += 30
|
||
if _check_volume_dry_up_before_rally(df):
|
||
accumulation += 30
|
||
|
||
return {
|
||
"demand_supply_ratio": ratio,
|
||
"volume_trend": trend,
|
||
"accumulation_score": accumulation,
|
||
}
|
||
|
||
|
||
# ── 辅助函数 ──
|
||
|
||
|
||
def _check_higher_highs(df: pd.DataFrame) -> bool:
|
||
"""检查最近是否形成更高高点形态
|
||
|
||
取近 20 日的局部高点,检查是否有上升趋势。
|
||
"""
|
||
if len(df) < 20:
|
||
return False
|
||
|
||
recent = df.tail(20)
|
||
highs = recent["high"].values
|
||
|
||
# 简单检查:最近的高点是否比之前的高点更高
|
||
# 将 20 日分为两段,各取最高点
|
||
first_half_max = highs[:10].max()
|
||
second_half_max = highs[10:].max()
|
||
|
||
return second_half_max >= first_half_max
|
||
|
||
|
||
def _check_bullish_volume_divergence(df: pd.DataFrame) -> bool:
|
||
"""检查量价配合是否健康(上涨放量,下跌缩量)"""
|
||
if len(df) < 6:
|
||
return False
|
||
|
||
recent = df.tail(5)
|
||
up_days = recent[recent["pct_chg"] > 0]
|
||
down_days = recent[recent["pct_chg"] <= 0]
|
||
|
||
if len(up_days) == 0 or len(down_days) == 0:
|
||
return len(up_days) > len(down_days) # 主要是上涨日也算
|
||
|
||
avg_up_vol = up_days["vol"].mean()
|
||
avg_down_vol = down_days["vol"].mean()
|
||
|
||
return avg_up_vol > avg_down_vol
|
||
|
||
|
||
def _check_volume_dry_up_before_rally(df: pd.DataFrame) -> bool:
|
||
"""检查是否有缩量后放量的突破前奏
|
||
|
||
经典突破模式:量能萎缩至极低,然后在突破日放量。
|
||
"""
|
||
if len(df) < 15:
|
||
return False
|
||
|
||
last = df.iloc[-1]
|
||
|
||
# 检查近 5-10 日内是否有极度缩量日
|
||
vol_ma_col = "vol_ma10" if "vol_ma10" in df.columns else "vol_ma5"
|
||
if vol_ma_col not in df.columns:
|
||
return False
|
||
|
||
recent_10 = df.tail(10)
|
||
had_dry_up = any(
|
||
row["vol"] < row[vol_ma_col] * 0.5
|
||
for _, row in recent_10.iterrows()
|
||
if not pd.isna(row[vol_ma_col]) and row[vol_ma_col] > 0
|
||
)
|
||
|
||
# 且当日量能放大
|
||
vol_ma_col_5 = "vol_ma5" if "vol_ma5" in df.columns else vol_ma_col
|
||
vol_expanding = (
|
||
not pd.isna(last[vol_ma_col_5])
|
||
and last[vol_ma_col_5] > 0
|
||
and last["vol"] > last[vol_ma_col_5] * 1.2
|
||
)
|
||
|
||
return had_dry_up and vol_expanding
|