astock-agent/backend/app/analysis/breakout_signals.py
2026-04-10 23:38:37 +08:00

649 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""趋势突破入场信号分类 + 供需分析 + 趋势评分
三种入场信号类型:
- 突破型 (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