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