1
This commit is contained in:
parent
ffe36b4055
commit
f7fca2e0b9
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
648
backend/app/analysis/breakout_signals.py
Normal file
648
backend/app/analysis/breakout_signals.py
Normal file
@ -0,0 +1,648 @@
|
|||||||
|
"""趋势突破入场信号分类 + 供需分析 + 趋势评分
|
||||||
|
|
||||||
|
三种入场信号类型:
|
||||||
|
- 突破型 (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
|
||||||
@ -266,3 +266,64 @@ def _score_intraday(quote: StockQuote) -> float:
|
|||||||
score += 3
|
score += 3
|
||||||
|
|
||||||
return score
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
async def intraday_sector_scan(prev_sectors: list[SectorInfo]) -> list[SectorInfo]:
|
||||||
|
"""盘中板块热度更新:用腾讯实时行情刷新板块涨幅和涨停数
|
||||||
|
|
||||||
|
基于前一日的板块列表(来自 Tushare),用成分股的实时行情
|
||||||
|
重新计算板块涨跌幅和涨停家数。
|
||||||
|
"""
|
||||||
|
if not prev_sectors:
|
||||||
|
return prev_sectors
|
||||||
|
|
||||||
|
# 收集所有板块的成分股
|
||||||
|
sector_members: dict[str, list[str]] = {}
|
||||||
|
all_codes = set()
|
||||||
|
for sector in prev_sectors:
|
||||||
|
members = tushare_client.get_ths_members(sector.sector_code)
|
||||||
|
if members.empty or "con_code" not in members.columns:
|
||||||
|
continue
|
||||||
|
codes = [c for c in members["con_code"].tolist() if "." in str(c)]
|
||||||
|
sector_members[sector.sector_code] = codes
|
||||||
|
all_codes.update(codes)
|
||||||
|
|
||||||
|
if not all_codes:
|
||||||
|
return prev_sectors
|
||||||
|
|
||||||
|
# 批量获取实时行情
|
||||||
|
quotes = await tencent_client.get_realtime_quotes_batch(list(all_codes))
|
||||||
|
if not quotes:
|
||||||
|
return prev_sectors
|
||||||
|
|
||||||
|
# 构建涨停集合
|
||||||
|
limit_up_codes = set()
|
||||||
|
for code, q in quotes.items():
|
||||||
|
if q.limit_up and q.price >= q.limit_up * 0.995:
|
||||||
|
limit_up_codes.add(code)
|
||||||
|
|
||||||
|
# 更新每个板块的数据
|
||||||
|
for sector in prev_sectors:
|
||||||
|
codes = sector_members.get(sector.sector_code, [])
|
||||||
|
if not codes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sector_quotes = [quotes[c] for c in codes if c in quotes]
|
||||||
|
if not sector_quotes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 实时涨跌幅(成分股均值)
|
||||||
|
pct_changes = [q.pct_chg for q in sector_quotes if q.pct_chg is not None]
|
||||||
|
if pct_changes:
|
||||||
|
sector.pct_change = round(sum(pct_changes) / len(pct_changes), 2)
|
||||||
|
|
||||||
|
# 实时涨停家数
|
||||||
|
sector.limit_up_count = len([c for c in codes if c in limit_up_codes])
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"盘中板块实时更新: {len(prev_sectors)} 个板块, "
|
||||||
|
f"涨幅最高={max(prev_sectors, key=lambda s: s.pct_change).sector_name} "
|
||||||
|
f"({max(s.pct_change for s in prev_sectors):.1f}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return prev_sectors
|
||||||
|
|||||||
@ -1,11 +1,17 @@
|
|||||||
"""板块热度扫描
|
"""板块热度扫描
|
||||||
|
|
||||||
综合板块涨幅、资金净流入、涨停家数、持续性,
|
综合板块涨幅、资金净流入、涨停家数、持续性,
|
||||||
输出热门板块排名。
|
输出热门板块排名及深度分析。
|
||||||
|
|
||||||
优化策略:先用板块资金流向批量数据预筛 Top 板块,
|
优化策略:先用板块资金流向批量数据预筛 Top 板块,
|
||||||
只对 Top 板块做逐个详细查询(ths_daily/ths_member),
|
只对 Top 板块做逐个详细查询(ths_daily/ths_member),
|
||||||
避免遍历全部数百个板块导致大量 API 调用。
|
避免遍历全部数百个板块导致大量 API 调用。
|
||||||
|
|
||||||
|
增强分析:
|
||||||
|
- 领涨股:每个板块中涨幅前3的成分股
|
||||||
|
- 资金趋势:近5日板块资金净流入走势
|
||||||
|
- 涨跌趋势:近5日板块涨跌幅走势
|
||||||
|
- 主力资金占比
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@ -37,7 +43,7 @@ def _normalize_score(values: list[float], reverse: bool = False) -> list[float]:
|
|||||||
|
|
||||||
|
|
||||||
def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
|
def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
|
||||||
"""扫描热门板块,返回按热度排名的板块列表"""
|
"""扫描热门板块,返回按热度排名的板块列表(含深度分析)"""
|
||||||
if not trade_date:
|
if not trade_date:
|
||||||
trade_date = tushare_client.get_latest_trade_date()
|
trade_date = tushare_client.get_latest_trade_date()
|
||||||
|
|
||||||
@ -54,18 +60,27 @@ def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
|
|||||||
logger.info(f"板块资金流向预筛: {len(sector_mf)} 个板块 -> Top {len(top_codes)} 进入详细分析")
|
logger.info(f"板块资金流向预筛: {len(sector_mf)} 个板块 -> Top {len(top_codes)} 进入详细分析")
|
||||||
|
|
||||||
# 构建资金流向查找表
|
# 构建资金流向查找表
|
||||||
|
# Tushare moneyflow_ind_ths 的金额单位是亿元,统一转换为万元
|
||||||
|
_UNIT_CONV = 10000
|
||||||
mf_lookup = {}
|
mf_lookup = {}
|
||||||
|
# 同时构建主力买卖数据(用于计算主力占比)
|
||||||
|
mf_detail = {}
|
||||||
for _, row in sector_mf.iterrows():
|
for _, row in sector_mf.iterrows():
|
||||||
mf_lookup[row["ts_code"]] = float(row["net_amount"])
|
mf_lookup[row["ts_code"]] = float(row["net_amount"]) * _UNIT_CONV
|
||||||
|
mf_detail[row["ts_code"]] = {
|
||||||
|
"net_amount": float(row["net_amount"]) * _UNIT_CONV,
|
||||||
|
"buy_elg_amount": float(row.get("buy_elg_amount", 0)) * _UNIT_CONV if pd.notna(row.get("buy_elg_amount")) else 0,
|
||||||
|
"sell_elg_amount": float(row.get("sell_elg_amount", 0)) * _UNIT_CONV if pd.notna(row.get("sell_elg_amount")) else 0,
|
||||||
|
"buy_lg_amount": float(row.get("buy_lg_amount", 0)) * _UNIT_CONV if pd.notna(row.get("buy_lg_amount")) else 0,
|
||||||
|
"sell_lg_amount": float(row.get("sell_lg_amount", 0)) * _UNIT_CONV if pd.notna(row.get("sell_lg_amount")) else 0,
|
||||||
|
}
|
||||||
|
|
||||||
# 构建板块名称查找表
|
# 构建板块名称查找表
|
||||||
# moneyflow_ind_ths 返回的是行业板块(881XXX.TI),自带 industry 列
|
|
||||||
name_lookup = {}
|
name_lookup = {}
|
||||||
if "industry" in sector_mf.columns:
|
if "industry" in sector_mf.columns:
|
||||||
for _, r in sector_mf.iterrows():
|
for _, r in sector_mf.iterrows():
|
||||||
if pd.notna(r.get("industry")):
|
if pd.notna(r.get("industry")):
|
||||||
name_lookup[r["ts_code"]] = str(r["industry"])
|
name_lookup[r["ts_code"]] = str(r["industry"])
|
||||||
# 补充:从 ths_index type=I(行业板块)获取名称
|
|
||||||
index_list = tushare_client.get_ths_index_list("I")
|
index_list = tushare_client.get_ths_index_list("I")
|
||||||
if not index_list.empty:
|
if not index_list.empty:
|
||||||
for _, r in index_list.iterrows():
|
for _, r in index_list.iterrows():
|
||||||
@ -75,22 +90,34 @@ def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
|
|||||||
# ── 第二步:获取涨跌停列表(1 次 API 调用)──
|
# ── 第二步:获取涨跌停列表(1 次 API 调用)──
|
||||||
limit_df = tushare_client.get_limit_list(trade_date)
|
limit_df = tushare_client.get_limit_list(trade_date)
|
||||||
limit_up_codes = set()
|
limit_up_codes = set()
|
||||||
|
# 同时收集涨停股的涨跌幅信息(用于领涨股展示)
|
||||||
|
limit_up_info: dict[str, dict] = {}
|
||||||
if not limit_df.empty:
|
if not limit_df.empty:
|
||||||
up_df = limit_df[limit_df["limit"] == "U"]
|
up_df = limit_df[limit_df["limit"] == "U"]
|
||||||
up_df = up_df[~up_df["name"].str.contains("ST", na=False)]
|
up_df = up_df[~up_df["name"].str.contains("ST", na=False)]
|
||||||
limit_up_codes = set(up_df["ts_code"].tolist())
|
limit_up_codes = set(up_df["ts_code"].tolist())
|
||||||
|
for _, row in up_df.iterrows():
|
||||||
|
limit_up_info[row["ts_code"]] = {
|
||||||
|
"name": row["name"],
|
||||||
|
"pct_chg": float(row.get("pct_chg", 10)),
|
||||||
|
"limit_times": int(row.get("limit_times", 1)),
|
||||||
|
}
|
||||||
|
|
||||||
# ── 第三步:只对 Top 板块做逐个详细查询 ──
|
# ── 第三步:获取全市场日线数据(用于领涨股计算)──
|
||||||
|
daily_all = tushare_client.get_daily_all(trade_date)
|
||||||
|
stock_basic = tushare_client.get_stock_basic()
|
||||||
|
|
||||||
|
# ── 第四步:只对 Top 板块做逐个详细查询 ──
|
||||||
sectors = []
|
sectors = []
|
||||||
|
|
||||||
for ts_code in top_codes:
|
for ts_code in top_codes:
|
||||||
# 板块名称从 ths_index 查找表获取
|
|
||||||
sector_name = name_lookup.get(ts_code, ts_code)
|
sector_name = name_lookup.get(ts_code, ts_code)
|
||||||
|
|
||||||
# 板块日线 - 获取近5日数据(1 次 API)
|
# 板块日线 - 获取近5日数据
|
||||||
ths_daily = tushare_client.get_ths_daily(ts_code, days=5)
|
ths_daily = tushare_client.get_ths_daily(ts_code, days=5)
|
||||||
pct_change = 0.0
|
pct_change = 0.0
|
||||||
days_continuous = 0
|
days_continuous = 0
|
||||||
|
pct_trend: list[float] = []
|
||||||
|
|
||||||
if not ths_daily.empty:
|
if not ths_daily.empty:
|
||||||
ths_daily = ths_daily.sort_values("trade_date")
|
ths_daily = ths_daily.sort_values("trade_date")
|
||||||
@ -101,6 +128,9 @@ def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
|
|||||||
today_data = ths_daily.tail(1)
|
today_data = ths_daily.tail(1)
|
||||||
pct_change = float(today_data["pct_change"].iloc[0]) if not today_data.empty else 0
|
pct_change = float(today_data["pct_change"].iloc[0]) if not today_data.empty else 0
|
||||||
|
|
||||||
|
# 近5日涨跌幅趋势
|
||||||
|
pct_trend = [round(float(d["pct_change"]), 2) for _, d in ths_daily.iterrows()]
|
||||||
|
|
||||||
# 连续上涨天数
|
# 连续上涨天数
|
||||||
for _, d in ths_daily.iloc[::-1].iterrows():
|
for _, d in ths_daily.iloc[::-1].iterrows():
|
||||||
if d["pct_change"] > 0:
|
if d["pct_change"] > 0:
|
||||||
@ -108,15 +138,75 @@ def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
|
|||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
# 板块资金净流入(从预筛数据中直接取)
|
# 板块资金净流入
|
||||||
capital_inflow = mf_lookup.get(ts_code, 0.0)
|
capital_inflow = mf_lookup.get(ts_code, 0.0)
|
||||||
|
|
||||||
# 板块内涨停家数(1 次 API)
|
# 主力资金占比 = (特大单净买 + 大单净买) / (特大单买卖总额 + 大单买卖总额)
|
||||||
|
main_force_ratio = 0.0
|
||||||
|
detail = mf_detail.get(ts_code, {})
|
||||||
|
total_main_amount = (detail.get("buy_elg_amount", 0) + detail.get("sell_elg_amount", 0) +
|
||||||
|
detail.get("buy_lg_amount", 0) + detail.get("sell_lg_amount", 0))
|
||||||
|
if total_main_amount > 0:
|
||||||
|
main_force_ratio = round(capital_inflow / total_main_amount * 100, 1)
|
||||||
|
|
||||||
|
# 板块成分股分析
|
||||||
limit_up_count = 0
|
limit_up_count = 0
|
||||||
|
leading_stocks: list[dict] = []
|
||||||
|
member_count = 0
|
||||||
|
turnover_avg = 0.0
|
||||||
|
|
||||||
members = tushare_client.get_ths_members(ts_code)
|
members = tushare_client.get_ths_members(ts_code)
|
||||||
if not members.empty and "con_code" in members.columns:
|
if not members.empty and "con_code" in members.columns:
|
||||||
member_codes = set(members["con_code"].tolist())
|
member_codes = list(members["con_code"].tolist())
|
||||||
limit_up_count = len(limit_up_codes & member_codes)
|
member_set = set(member_codes)
|
||||||
|
member_count = len(member_codes)
|
||||||
|
limit_up_count = len(limit_up_codes & member_set)
|
||||||
|
|
||||||
|
# 领涨股:从当日全市场日级数据中筛选该板块成分股,按涨幅排序取前3
|
||||||
|
if not daily_all.empty:
|
||||||
|
sector_daily = daily_all[daily_all["ts_code"].isin(member_set)].copy()
|
||||||
|
# 排除 ST
|
||||||
|
if not stock_basic.empty:
|
||||||
|
st_set = set(stock_basic[stock_basic["name"].str.contains("ST", na=False)]["ts_code"])
|
||||||
|
sector_daily = sector_daily[~sector_daily["ts_code"].isin(st_set)]
|
||||||
|
|
||||||
|
sector_daily = sector_daily.sort_values("pct_chg", ascending=False)
|
||||||
|
# 计算板块平均换手率
|
||||||
|
if "turnover_rate" in sector_daily.columns:
|
||||||
|
turnover_values = sector_daily["turnover_rate"].dropna()
|
||||||
|
if len(turnover_values) > 0:
|
||||||
|
turnover_avg = round(float(turnover_values.mean()), 2)
|
||||||
|
|
||||||
|
# 构建名称查找
|
||||||
|
name_map = {}
|
||||||
|
if not stock_basic.empty:
|
||||||
|
for _, br in stock_basic.iterrows():
|
||||||
|
name_map[br["ts_code"]] = br["name"]
|
||||||
|
if "con_name" in members.columns:
|
||||||
|
for _, m in members.iterrows():
|
||||||
|
if pd.notna(m.get("con_name")):
|
||||||
|
name_map[m["con_code"]] = m["con_name"]
|
||||||
|
|
||||||
|
# 取涨幅前3
|
||||||
|
for _, sr in sector_daily.head(3).iterrows():
|
||||||
|
leading_stocks.append({
|
||||||
|
"ts_code": sr["ts_code"],
|
||||||
|
"name": name_map.get(sr["ts_code"], sr["ts_code"]),
|
||||||
|
"pct_chg": round(float(sr["pct_chg"]), 2),
|
||||||
|
"amount": round(float(sr.get("amount", 0)), 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 涨停股也在成分股中的,补充到领涨股(如未在top3中)
|
||||||
|
for code in (limit_up_codes & member_set):
|
||||||
|
if code not in [s["ts_code"] for s in leading_stocks]:
|
||||||
|
info = limit_up_info.get(code, {})
|
||||||
|
leading_stocks.append({
|
||||||
|
"ts_code": code,
|
||||||
|
"name": info.get("name", code),
|
||||||
|
"pct_chg": info.get("pct_chg", 10),
|
||||||
|
"amount": 0,
|
||||||
|
"limit_times": info.get("limit_times", 1),
|
||||||
|
})
|
||||||
|
|
||||||
sectors.append(SectorInfo(
|
sectors.append(SectorInfo(
|
||||||
sector_code=ts_code,
|
sector_code=ts_code,
|
||||||
@ -125,6 +215,11 @@ def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
|
|||||||
capital_inflow=round(capital_inflow, 2),
|
capital_inflow=round(capital_inflow, 2),
|
||||||
limit_up_count=limit_up_count,
|
limit_up_count=limit_up_count,
|
||||||
days_continuous=days_continuous,
|
days_continuous=days_continuous,
|
||||||
|
member_count=member_count,
|
||||||
|
leading_stocks=leading_stocks,
|
||||||
|
pct_trend=pct_trend,
|
||||||
|
turnover_avg=turnover_avg,
|
||||||
|
main_force_ratio=main_force_ratio,
|
||||||
))
|
))
|
||||||
|
|
||||||
if not sectors:
|
if not sectors:
|
||||||
@ -133,13 +228,13 @@ def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
|
|||||||
# ── 板块阶段判定 ──
|
# ── 板块阶段判定 ──
|
||||||
for s in sectors:
|
for s in sectors:
|
||||||
if s.days_continuous <= 2:
|
if s.days_continuous <= 2:
|
||||||
s.stage = "early" # 启动期,安全
|
s.stage = "early"
|
||||||
elif s.days_continuous == 3:
|
elif s.days_continuous == 3:
|
||||||
s.stage = "mid" # 发展期,正常
|
s.stage = "mid"
|
||||||
elif s.days_continuous == 4:
|
elif s.days_continuous == 4:
|
||||||
s.stage = "late" # 后期,谨慎
|
s.stage = "late"
|
||||||
else:
|
else:
|
||||||
s.stage = "end" # 尾声,高风险
|
s.stage = "end"
|
||||||
|
|
||||||
# ── 综合评分 ──
|
# ── 综合评分 ──
|
||||||
pct_scores = _normalize_score([s.pct_change for s in sectors])
|
pct_scores = _normalize_score([s.pct_change for s in sectors])
|
||||||
@ -154,20 +249,17 @@ def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
|
|||||||
lim_scores[i] * 0.25 +
|
lim_scores[i] * 0.25 +
|
||||||
con_scores[i] * 0.20
|
con_scores[i] * 0.20
|
||||||
)
|
)
|
||||||
# 连续2日以上资金流入的板块加分
|
|
||||||
if s.days_continuous >= 2:
|
if s.days_continuous >= 2:
|
||||||
heat += 5
|
heat += 5
|
||||||
# 首日大涨(昨日涨幅<=0,今日>3%)可视为新热点,加分
|
|
||||||
if s.days_continuous == 1 and s.pct_change > 3:
|
if s.days_continuous == 1 and s.pct_change > 3:
|
||||||
heat += 3
|
heat += 3
|
||||||
|
|
||||||
# 板块阶段调整:早期加分,尾声减分(防追高)
|
|
||||||
if s.stage == "early":
|
if s.stage == "early":
|
||||||
heat += 8 # 启动期,介入安全,大幅加分
|
heat += 8
|
||||||
elif s.stage == "late":
|
elif s.stage == "late":
|
||||||
heat -= 5 # 后期,风险上升
|
heat -= 5
|
||||||
elif s.stage == "end":
|
elif s.stage == "end":
|
||||||
heat -= 12 # 尾声,大幅减分
|
heat -= 12
|
||||||
|
|
||||||
s.heat_score = round(max(0, min(heat, 100)), 1)
|
s.heat_score = round(max(0, min(heat, 100)), 1)
|
||||||
|
|
||||||
@ -175,7 +267,10 @@ def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
|
|||||||
|
|
||||||
top = sectors[:settings.top_sector_count]
|
top = sectors[:settings.top_sector_count]
|
||||||
for s in top:
|
for s in top:
|
||||||
logger.info(f"热门板块: {s.sector_name} 涨幅{s.pct_change}% 资金{s.capital_inflow}万 "
|
leaders = ", ".join(f'{l["name"]}({l["pct_chg"]}%)' for l in s.leading_stocks[:3])
|
||||||
f"涨停{s.limit_up_count} 连续{s.days_continuous}天 阶段={s.stage} 热度{s.heat_score}")
|
inflow_display = f"{s.capital_inflow / 10000:.1f}亿" if s.capital_inflow >= 10000 else f"{s.capital_inflow:.0f}万"
|
||||||
|
logger.info(f"热门板块: {s.sector_name} 涨幅{s.pct_change}% 资金{inflow_display} "
|
||||||
|
f"涨停{s.limit_up_count} 连续{s.days_continuous}天 阶段={s.stage} 热度{s.heat_score} "
|
||||||
|
f"领涨=[{leaders}]")
|
||||||
|
|
||||||
return sectors
|
return sectors
|
||||||
|
|||||||
@ -83,6 +83,7 @@ def add_all_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
|||||||
# 量均线
|
# 量均线
|
||||||
df["vol_ma5"] = calc_volume_ma(vol, 5)
|
df["vol_ma5"] = calc_volume_ma(vol, 5)
|
||||||
df["vol_ma10"] = calc_volume_ma(vol, 10)
|
df["vol_ma10"] = calc_volume_ma(vol, 10)
|
||||||
|
df["vol_ma20"] = calc_volume_ma(vol, 20)
|
||||||
|
|
||||||
# 涨跌幅(如果没有 pct_chg 列)
|
# 涨跌幅(如果没有 pct_chg 列)
|
||||||
if "pct_chg" not in df.columns:
|
if "pct_chg" not in df.columns:
|
||||||
|
|||||||
516
backend/app/analysis/trend_scanner.py
Normal file
516
backend/app/analysis/trend_scanner.py
Normal file
@ -0,0 +1,516 @@
|
|||||||
|
"""趋势突破三阶段扫描管道
|
||||||
|
|
||||||
|
Phase 1: 全市场批量预筛 — get_daily_all × 5 天 + get_daily_basic → ~300 候选
|
||||||
|
Phase 2: 资金流批量过滤 — get_moneyflow_batch → ~80 候选
|
||||||
|
Phase 3: 逐股深度分析 — get_stock_daily(120d) + 入场信号 + 供需评分 → ~20 推荐
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.data.tushare_client import tushare_client
|
||||||
|
from app.analysis.technical import add_all_indicators
|
||||||
|
from app.analysis.breakout_signals import (
|
||||||
|
classify_entry_signal,
|
||||||
|
score_trend_timing,
|
||||||
|
score_supply_demand,
|
||||||
|
analyze_volume_pattern,
|
||||||
|
EntrySignal,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def scan_trend_breakout(
|
||||||
|
trade_date: str = None,
|
||||||
|
market_temp=None,
|
||||||
|
hot_sectors: list = None,
|
||||||
|
intraday: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""统一趋势突破扫描 — 三阶段管道
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict] — 每个字典包含:
|
||||||
|
ts_code, name, sector, entry_signal_type, entry_signal_score,
|
||||||
|
trend_timing_score, supply_demand_score, capital_score,
|
||||||
|
main_net_inflow, inflow_ratio, turnover_rate, volume_ratio,
|
||||||
|
circ_mv, pe, pb, volume_trend, demand_supply_ratio
|
||||||
|
"""
|
||||||
|
if not trade_date:
|
||||||
|
trade_date = tushare_client.get_latest_trade_date()
|
||||||
|
|
||||||
|
logger.info(f"=== 趋势突破扫描 (trade_date={trade_date}) ===")
|
||||||
|
|
||||||
|
# Phase 1: 批量预筛
|
||||||
|
candidates = _bulk_pre_filter(trade_date)
|
||||||
|
if candidates.empty:
|
||||||
|
logger.info("Phase 1 预筛无候选")
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"Phase 1 预筛: {len(candidates)} 只候选")
|
||||||
|
|
||||||
|
# Phase 2: 资金流过滤
|
||||||
|
candidates = _bulk_capital_filter(candidates, trade_date)
|
||||||
|
if candidates.empty:
|
||||||
|
logger.info("Phase 2 资金过滤后无候选")
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"Phase 2 资金过滤: {len(candidates)} 只候选")
|
||||||
|
|
||||||
|
# Phase 3: 逐股深度分析
|
||||||
|
results = await _deep_analysis(candidates, trade_date, market_temp, hot_sectors or [], intraday)
|
||||||
|
|
||||||
|
logger.info(f"Phase 3 深度分析: {len(results)} 只推荐")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _bulk_pre_filter(trade_date: str) -> pd.DataFrame:
|
||||||
|
"""Phase 1: 批量预筛
|
||||||
|
|
||||||
|
利用 get_daily_all × 5 天构建迷你时序,用 5 日趋势初筛。
|
||||||
|
合并 get_daily_basic 过滤市值/换手率。
|
||||||
|
排除 ST 和次新股。
|
||||||
|
"""
|
||||||
|
# 获取交易日历
|
||||||
|
dates = tushare_client.get_trade_dates()
|
||||||
|
if not dates:
|
||||||
|
logger.warning("Phase 1: 无法获取交易日历")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# 取最近 5 个交易日(含当日)
|
||||||
|
recent_dates = dates[-5:] if len(dates) >= 5 else dates
|
||||||
|
if trade_date not in recent_dates:
|
||||||
|
recent_dates = recent_dates[-4:] + [trade_date] if len(recent_dates) >= 4 else [trade_date]
|
||||||
|
|
||||||
|
logger.info(f"Phase 1: 使用交易日 {recent_dates}")
|
||||||
|
|
||||||
|
# 获取多日全市场数据
|
||||||
|
daily_frames = []
|
||||||
|
for d in recent_dates:
|
||||||
|
df = tushare_client.get_daily_all(d)
|
||||||
|
if not df.empty:
|
||||||
|
daily_frames.append(df)
|
||||||
|
logger.debug(f"Phase 1: {d} 获取到 {len(df)} 只股票数据")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Phase 1: {d} 无数据")
|
||||||
|
|
||||||
|
if len(daily_frames) < 2:
|
||||||
|
# 至少需要 2 天数据做趋势判断
|
||||||
|
if daily_frames:
|
||||||
|
logger.info("Phase 1: 仅有1天数据,跳过趋势筛选")
|
||||||
|
return _filter_daily_basic(daily_frames[0], trade_date)
|
||||||
|
logger.warning("Phase 1: 完全无数据")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# 拼接多日数据
|
||||||
|
all_daily = pd.concat(daily_frames, ignore_index=True)
|
||||||
|
logger.info(f"Phase 1: 拼接后总记录数 {len(all_daily)}")
|
||||||
|
|
||||||
|
# 计算每只股票的 5 日趋势
|
||||||
|
stock_groups = all_daily.groupby("ts_code")
|
||||||
|
|
||||||
|
# 分步统计
|
||||||
|
total_stocks = len(stock_groups)
|
||||||
|
count_has_data = 0
|
||||||
|
count_main_board = 0
|
||||||
|
count_no_limit = 0
|
||||||
|
count_positive_return = 0
|
||||||
|
count_above_avg = 0
|
||||||
|
count_near_high = 0
|
||||||
|
|
||||||
|
trend_data = []
|
||||||
|
for ts_code, group in stock_groups:
|
||||||
|
if len(group) < 2:
|
||||||
|
continue
|
||||||
|
count_has_data += 1
|
||||||
|
|
||||||
|
group = group.sort_values("trade_date")
|
||||||
|
latest = group.iloc[-1]
|
||||||
|
|
||||||
|
# 排除非主板(只保留 00/30/60 开头)
|
||||||
|
code_prefix = ts_code[:2]
|
||||||
|
if code_prefix not in ("00", "30", "60"):
|
||||||
|
continue
|
||||||
|
count_main_board += 1
|
||||||
|
|
||||||
|
# 排除涨跌停
|
||||||
|
pct = latest["pct_chg"]
|
||||||
|
if pd.isna(pct) or abs(pct) > 9.5:
|
||||||
|
continue
|
||||||
|
count_no_limit += 1
|
||||||
|
|
||||||
|
# 5 日涨幅
|
||||||
|
first_close = group.iloc[0]["close"]
|
||||||
|
last_close = latest["close"]
|
||||||
|
if first_close <= 0:
|
||||||
|
continue
|
||||||
|
ret_5d = (last_close - first_close) / first_close * 100
|
||||||
|
|
||||||
|
# 条件:5 日涨幅 > -2%(允许小幅回调) 且 < 20%(未过热)
|
||||||
|
if ret_5d <= -2 or ret_5d >= 20:
|
||||||
|
continue
|
||||||
|
count_positive_return += 1
|
||||||
|
|
||||||
|
# 5 日均价
|
||||||
|
avg_close = group["close"].mean()
|
||||||
|
if avg_close <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 收盘价 >= 5 日均价 * 0.98(允许略低于均线)
|
||||||
|
if last_close < avg_close * 0.98:
|
||||||
|
continue
|
||||||
|
count_above_avg += 1
|
||||||
|
|
||||||
|
# 距 5 日高点 < 8%(放宽,不要求紧贴高点)
|
||||||
|
high_5d = group["high"].max()
|
||||||
|
if high_5d <= 0:
|
||||||
|
continue
|
||||||
|
dist_from_high = (high_5d - last_close) / high_5d * 100
|
||||||
|
if dist_from_high > 8:
|
||||||
|
continue
|
||||||
|
count_near_high += 1
|
||||||
|
|
||||||
|
trend_data.append({
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"close": last_close,
|
||||||
|
"pct_chg_today": pct,
|
||||||
|
"return_5d": round(ret_5d, 2),
|
||||||
|
"dist_from_high_5d": round(dist_from_high, 2),
|
||||||
|
"vol_today": latest["vol"],
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Phase 1 趋势筛选: "
|
||||||
|
f"总{total_stocks} → 有数据{count_has_data} → 主板{count_main_board} → "
|
||||||
|
f"非涨跌停{count_no_limit} → 5日涨幅>-2%{count_positive_return} → "
|
||||||
|
f"近均线{count_above_avg} → 近高点{count_near_high}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not trend_data:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
candidates = pd.DataFrame(trend_data)
|
||||||
|
|
||||||
|
# 合并 daily_basic 过滤(使用更宽松的换手率)
|
||||||
|
return _filter_daily_basic(candidates, trade_date)
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_daily_basic(candidates: pd.DataFrame, trade_date: str) -> pd.DataFrame:
|
||||||
|
"""使用 daily_basic 过滤市值、换手率,排除 ST 和次新"""
|
||||||
|
basic = tushare_client.get_daily_basic(trade_date)
|
||||||
|
if basic.empty:
|
||||||
|
logger.warning("Phase 1: daily_basic 无数据")
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
# 股票基本信息(排除 ST 和次新)
|
||||||
|
stock_basic = tushare_client.get_stock_basic()
|
||||||
|
exclude_codes = set()
|
||||||
|
if not stock_basic.empty:
|
||||||
|
# ST
|
||||||
|
st_codes = set(stock_basic[stock_basic["name"].str.contains("ST", na=False)]["ts_code"])
|
||||||
|
exclude_codes.update(st_codes)
|
||||||
|
# 次新
|
||||||
|
cutoff = (datetime.now() - timedelta(days=settings.min_list_days)).strftime("%Y%m%d")
|
||||||
|
new_codes = set(stock_basic[stock_basic["list_date"] > cutoff]["ts_code"])
|
||||||
|
exclude_codes.update(new_codes)
|
||||||
|
|
||||||
|
# Tushare circ_mv 单位是万元,转为亿元
|
||||||
|
basic["circ_mv"] = basic["circ_mv"] / 10000
|
||||||
|
|
||||||
|
# Phase 1 使用更宽松的换手率门槛(2%)以保留更多候选
|
||||||
|
min_tr = min(settings.min_turnover_rate, 2.0)
|
||||||
|
basic_filtered = basic[
|
||||||
|
(basic["circ_mv"] >= settings.min_circ_mv) &
|
||||||
|
(basic["circ_mv"] <= settings.max_circ_mv) &
|
||||||
|
(basic["turnover_rate"] >= min_tr) &
|
||||||
|
(basic["turnover_rate"] <= settings.max_turnover_rate) &
|
||||||
|
(~basic["ts_code"].isin(exclude_codes))
|
||||||
|
].copy()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Phase 1 daily_basic: 全市场{len(basic)}只 → "
|
||||||
|
f"过滤后{len(basic_filtered)}只 "
|
||||||
|
f"(circ_mv {settings.min_circ_mv}-{settings.max_circ_mv}亿, "
|
||||||
|
f"turnover >={min_tr}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if basic_filtered.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# 合并候选和 basic 数据
|
||||||
|
if "circ_mv" in candidates.columns:
|
||||||
|
merged = candidates
|
||||||
|
else:
|
||||||
|
# candidates 没有 basic 列,通过 ts_code 合并
|
||||||
|
basic_subset = basic_filtered[["ts_code", "turnover_rate", "volume_ratio",
|
||||||
|
"circ_mv", "pe", "pb"]].drop_duplicates("ts_code")
|
||||||
|
# 如果 candidates 有 ts_code 列
|
||||||
|
if "ts_code" in candidates.columns:
|
||||||
|
merged = candidates.merge(basic_subset, on="ts_code", how="inner")
|
||||||
|
else:
|
||||||
|
# candidates 是 get_daily_all 返回的原始 df
|
||||||
|
merged = basic_subset
|
||||||
|
|
||||||
|
logger.info(f"Phase 1 合并后: {len(merged)} 只候选")
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _bulk_capital_filter(candidates: pd.DataFrame, trade_date: str) -> pd.DataFrame:
|
||||||
|
"""Phase 2: 资金流批量过滤
|
||||||
|
|
||||||
|
硬条件:主力净流入 > 0(特大单 + 大单净买为正)
|
||||||
|
"""
|
||||||
|
mf = tushare_client.get_moneyflow_batch(trade_date)
|
||||||
|
if mf.empty:
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
# 计算主力净流入 = (特大单买入-特大单卖出) + (大单买入-大单卖出)
|
||||||
|
mf["main_net_inflow"] = (
|
||||||
|
(mf["buy_elg_amount"] - mf["sell_elg_amount"]) +
|
||||||
|
(mf["buy_lg_amount"] - mf["sell_lg_amount"])
|
||||||
|
)
|
||||||
|
|
||||||
|
# 计算流入比例
|
||||||
|
total = (
|
||||||
|
mf["buy_elg_amount"] + mf["sell_elg_amount"] +
|
||||||
|
mf["buy_lg_amount"] + mf["sell_lg_amount"] +
|
||||||
|
mf["buy_md_amount"] + mf["sell_md_amount"] +
|
||||||
|
mf["buy_sm_amount"] + mf["sell_sm_amount"]
|
||||||
|
)
|
||||||
|
mf["inflow_ratio"] = mf["main_net_inflow"] / total.replace(0, np.nan) * 100
|
||||||
|
mf["inflow_ratio"] = mf["inflow_ratio"].fillna(0)
|
||||||
|
|
||||||
|
# 只保留主力净流入 > 0 的
|
||||||
|
mf_positive = mf[mf["main_net_inflow"] > 0][["ts_code", "main_net_inflow", "inflow_ratio"]]
|
||||||
|
|
||||||
|
# 合并候选
|
||||||
|
if "ts_code" in candidates.columns:
|
||||||
|
merged = candidates.merge(mf_positive, on="ts_code", how="inner")
|
||||||
|
else:
|
||||||
|
merged = mf_positive
|
||||||
|
|
||||||
|
# 按主力净流入排序,取 top 100
|
||||||
|
if not merged.empty:
|
||||||
|
merged = merged.sort_values("main_net_inflow", ascending=False).head(100)
|
||||||
|
|
||||||
|
return merged.reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _deep_analysis(
|
||||||
|
candidates: pd.DataFrame,
|
||||||
|
trade_date: str,
|
||||||
|
market_temp,
|
||||||
|
hot_sectors: list,
|
||||||
|
intraday: bool,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Phase 3: 逐股深度分析
|
||||||
|
|
||||||
|
对每只候选获取 120 日 K 线,计算技术指标,
|
||||||
|
分类入场信号,评分供需关系。
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from app.analysis.signals import generate_signals
|
||||||
|
from app.analysis.capital_flow import _score_valuation
|
||||||
|
|
||||||
|
# 获取股票名称映射
|
||||||
|
stock_basic = tushare_client.get_stock_basic()
|
||||||
|
name_map = {}
|
||||||
|
industry_map = {}
|
||||||
|
if not stock_basic.empty:
|
||||||
|
for _, row in stock_basic.iterrows():
|
||||||
|
name_map[row["ts_code"]] = row["name"]
|
||||||
|
industry_map[row["ts_code"]] = row.get("industry", "")
|
||||||
|
|
||||||
|
# 获取多日资金流(用于资金持续性评分)
|
||||||
|
mf_history = _get_multi_day_moneyflow(candidates, trade_date)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
total = len(candidates)
|
||||||
|
signal_counts = {"breakout": 0, "pullback": 0, "launch": 0, "none": 0}
|
||||||
|
|
||||||
|
for idx, row in candidates.iterrows():
|
||||||
|
ts_code = row["ts_code"] if "ts_code" in candidates.columns else ""
|
||||||
|
|
||||||
|
if not ts_code:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = name_map.get(ts_code, ts_code)
|
||||||
|
sector = industry_map.get(ts_code, "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取 120 日 K 线
|
||||||
|
df = tushare_client.get_stock_daily(ts_code, 120)
|
||||||
|
if df.empty or len(df) < 30:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 添加技术指标
|
||||||
|
df = add_all_indicators(df)
|
||||||
|
|
||||||
|
# 入场信号分类
|
||||||
|
entry_signal = classify_entry_signal(df)
|
||||||
|
signal_type = entry_signal["signal_type"]
|
||||||
|
if signal_type == EntrySignal.NONE:
|
||||||
|
signal_counts["none"] += 1
|
||||||
|
continue
|
||||||
|
signal_counts[signal_type.value] += 1
|
||||||
|
|
||||||
|
# 趋势&时机评分
|
||||||
|
trend_score = score_trend_timing(df, entry_signal)
|
||||||
|
|
||||||
|
# 供需评分
|
||||||
|
sd_score = score_supply_demand(df)
|
||||||
|
|
||||||
|
# 量价模式分析
|
||||||
|
vol_pattern = analyze_volume_pattern(df)
|
||||||
|
|
||||||
|
# 技术信号(复用现有 generate_signals)
|
||||||
|
tech_signal = generate_signals(ts_code, name)
|
||||||
|
|
||||||
|
# 资金流评分
|
||||||
|
capital_score = _score_capital(
|
||||||
|
row, mf_history.get(ts_code, pd.DataFrame())
|
||||||
|
)
|
||||||
|
|
||||||
|
# 估值评分(作为辅助参考)
|
||||||
|
pe = row.get("pe")
|
||||||
|
pb = row.get("pb")
|
||||||
|
valuation_score = _score_valuation(pe, pb)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"name": name,
|
||||||
|
"sector": sector,
|
||||||
|
"entry_signal_type": entry_signal["signal_type"].value,
|
||||||
|
"entry_signal_score": round(entry_signal["signal_score"], 1),
|
||||||
|
"entry_signal_details": entry_signal.get("details", {}),
|
||||||
|
"trend_timing_score": round(trend_score, 1),
|
||||||
|
"supply_demand_score": round(sd_score, 1),
|
||||||
|
"capital_score": round(capital_score, 1),
|
||||||
|
"valuation_score": round(valuation_score, 1),
|
||||||
|
"technical_score": round(tech_signal.score, 1),
|
||||||
|
"position_score": round(tech_signal.position_score, 1),
|
||||||
|
"main_net_inflow": row.get("main_net_inflow", 0),
|
||||||
|
"inflow_ratio": round(row.get("inflow_ratio", 0), 2),
|
||||||
|
"turnover_rate": row.get("turnover_rate"),
|
||||||
|
"volume_ratio": row.get("volume_ratio"),
|
||||||
|
"circ_mv": row.get("circ_mv"),
|
||||||
|
"pe": pe,
|
||||||
|
"pb": pb,
|
||||||
|
"volume_trend": vol_pattern["volume_trend"],
|
||||||
|
"demand_supply_ratio": vol_pattern["demand_supply_ratio"],
|
||||||
|
# 技术信号详情(用于生成推荐理由)
|
||||||
|
"tech_signal": tech_signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(results) >= settings.top_stock_count:
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"深度分析 {ts_code} 失败: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 让出控制权,避免阻塞事件循环
|
||||||
|
if idx % 10 == 0:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Phase 3 入场信号分布: "
|
||||||
|
f"突破={signal_counts['breakout']} 回踩={signal_counts['pullback']} "
|
||||||
|
f"启动={signal_counts['launch']} 无信号={signal_counts['none']} "
|
||||||
|
f"(共分析{total}只)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _get_multi_day_moneyflow(candidates: pd.DataFrame, trade_date: str) -> dict[str, pd.DataFrame]:
|
||||||
|
"""获取候选股票近 5 日资金流数据(用于资金持续性评分)
|
||||||
|
|
||||||
|
利用 get_stock_moneyflow 逐只获取(仅对 Phase 2 过滤后的 ~80 只)。
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
if "ts_code" not in candidates.columns:
|
||||||
|
return result
|
||||||
|
|
||||||
|
for ts_code in candidates["ts_code"].values[:80]:
|
||||||
|
try:
|
||||||
|
df = tushare_client.get_stock_moneyflow(ts_code, 5)
|
||||||
|
if not df.empty:
|
||||||
|
result[ts_code] = df
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _score_capital(stock_row: dict | pd.Series, mf_history: pd.DataFrame) -> float:
|
||||||
|
"""资金流评分 (0-100)
|
||||||
|
|
||||||
|
维度:
|
||||||
|
- 当日主力净流入规模 (35)
|
||||||
|
- 资金持续性 (35): 近 N 日中主力净流入为正的天数
|
||||||
|
- 流入比 (15)
|
||||||
|
- 量比 (15)
|
||||||
|
"""
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
main_net = stock_row.get("main_net_inflow", 0) or 0
|
||||||
|
inflow_ratio = stock_row.get("inflow_ratio", 0) or 0
|
||||||
|
volume_ratio = stock_row.get("volume_ratio")
|
||||||
|
|
||||||
|
# 当日主力净流入规模 (35)
|
||||||
|
if main_net > 10000:
|
||||||
|
score += 35
|
||||||
|
elif main_net > 5000:
|
||||||
|
score += 28
|
||||||
|
elif main_net > 2000:
|
||||||
|
score += 20
|
||||||
|
elif main_net > 500:
|
||||||
|
score += 12
|
||||||
|
elif main_net > 0:
|
||||||
|
score += 5
|
||||||
|
|
||||||
|
# 资金持续性 (35)
|
||||||
|
if not mf_history.empty and len(mf_history) >= 2:
|
||||||
|
positive_days = 0
|
||||||
|
for _, r in mf_history.iterrows():
|
||||||
|
net = (r.get("buy_elg_amount", 0) - r.get("sell_elg_amount", 0) +
|
||||||
|
r.get("buy_lg_amount", 0) - r.get("sell_lg_amount", 0))
|
||||||
|
if net > 0:
|
||||||
|
positive_days += 1
|
||||||
|
total_days = len(mf_history)
|
||||||
|
if total_days >= 3 and positive_days >= total_days * 0.7:
|
||||||
|
score += 35
|
||||||
|
elif total_days >= 2 and positive_days >= total_days * 0.5:
|
||||||
|
score += 25
|
||||||
|
elif positive_days >= 1:
|
||||||
|
score += 12
|
||||||
|
else:
|
||||||
|
# 只有一天数据且为正(已经通过 Phase 2 过滤)
|
||||||
|
score += 15
|
||||||
|
|
||||||
|
# 流入比 (15)
|
||||||
|
if inflow_ratio > 15:
|
||||||
|
score += 15
|
||||||
|
elif inflow_ratio > 10:
|
||||||
|
score += 12
|
||||||
|
elif inflow_ratio > 5:
|
||||||
|
score += 8
|
||||||
|
elif inflow_ratio > 0:
|
||||||
|
score += 4
|
||||||
|
|
||||||
|
# 量比 (15)
|
||||||
|
if volume_ratio:
|
||||||
|
if volume_ratio > 2.0:
|
||||||
|
score += 15
|
||||||
|
elif volume_ratio > 1.5:
|
||||||
|
score += 10
|
||||||
|
elif volume_ratio > 1.0:
|
||||||
|
score += 5
|
||||||
|
|
||||||
|
return min(score, 100)
|
||||||
Binary file not shown.
Binary file not shown.
@ -52,6 +52,7 @@ async def get_latest():
|
|||||||
"llm_analysis": r.llm_analysis,
|
"llm_analysis": r.llm_analysis,
|
||||||
"llm_score": r.llm_score,
|
"llm_score": r.llm_score,
|
||||||
"strategy": r.strategy,
|
"strategy": r.strategy,
|
||||||
|
"entry_signal_type": r.entry_signal_type,
|
||||||
"scan_session": r.scan_session,
|
"scan_session": r.scan_session,
|
||||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
}
|
}
|
||||||
@ -88,6 +89,5 @@ async def get_scan_status():
|
|||||||
|
|
||||||
@router.get("/history")
|
@router.get("/history")
|
||||||
async def get_history(days: int = 7):
|
async def get_history(days: int = 7):
|
||||||
"""获取历史推荐"""
|
"""获取历史推荐(按日期分组)"""
|
||||||
rows = await get_recommendation_history(days)
|
return await get_recommendation_history(days)
|
||||||
return rows
|
|
||||||
|
|||||||
@ -66,13 +66,26 @@ async def _enrich_sectors_realtime(sectors_data: list[dict]) -> list[dict]:
|
|||||||
1 for q in member_quotes
|
1 for q in member_quotes
|
||||||
if q.limit_up and q.price >= q.limit_up * 0.995
|
if q.limit_up and q.price >= q.limit_up * 0.995
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 盘中更新领涨股
|
||||||
|
sorted_quotes = sorted(member_quotes, key=lambda q: q.pct_chg, reverse=True)
|
||||||
|
s["leading_stocks_realtime"] = [
|
||||||
|
{
|
||||||
|
"ts_code": q.ts_code,
|
||||||
|
"name": q.name or q.ts_code,
|
||||||
|
"pct_chg": round(q.pct_chg, 2),
|
||||||
|
"amount": round(q.amount, 0),
|
||||||
|
}
|
||||||
|
for q in sorted_quotes[:3]
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
s["realtime_pct_change"] = None
|
s["realtime_pct_change"] = None
|
||||||
s["realtime_limit_up_count"] = None
|
s["realtime_limit_up_count"] = None
|
||||||
|
s["leading_stocks_realtime"] = None
|
||||||
|
|
||||||
s["is_realtime"] = True
|
s["is_realtime"] = True
|
||||||
|
|
||||||
# 盘中按实时涨幅重新排序(涨幅高的排前面)
|
# 盘中按实时涨幅重新排序
|
||||||
sectors_data.sort(key=lambda s: s.get("realtime_pct_change") or 0, reverse=True)
|
sectors_data.sort(key=lambda s: s.get("realtime_pct_change") or 0, reverse=True)
|
||||||
|
|
||||||
return sectors_data
|
return sectors_data
|
||||||
@ -92,6 +105,12 @@ async def get_hot_sectors(limit: int = 10):
|
|||||||
"days_continuous": s.days_continuous,
|
"days_continuous": s.days_continuous,
|
||||||
"heat_score": s.heat_score,
|
"heat_score": s.heat_score,
|
||||||
"stage": s.stage,
|
"stage": s.stage,
|
||||||
|
# 增强分析字段
|
||||||
|
"member_count": s.member_count,
|
||||||
|
"leading_stocks": s.leading_stocks,
|
||||||
|
"pct_trend": s.pct_trend,
|
||||||
|
"turnover_avg": s.turnover_avg,
|
||||||
|
"main_force_ratio": s.main_force_ratio,
|
||||||
}
|
}
|
||||||
for s in sectors[:limit]
|
for s in sectors[:limit]
|
||||||
]
|
]
|
||||||
|
|||||||
@ -43,6 +43,11 @@ class Settings(BaseSettings):
|
|||||||
# 风控
|
# 风控
|
||||||
stop_loss_pct: float = 5.0 # 止损比例 %
|
stop_loss_pct: float = 5.0 # 止损比例 %
|
||||||
|
|
||||||
|
# 趋势突破策略参数
|
||||||
|
breakout_min_volume_ratio: float = 1.2 # 突破型最小量比
|
||||||
|
pullback_max_shrink_ratio: float = 0.85 # 回踩型最大缩量比
|
||||||
|
consolidation_max_range_pct: float = 8.0 # 启动型最大整理振幅 %
|
||||||
|
|
||||||
# LLM (DeepSeek)
|
# LLM (DeepSeek)
|
||||||
deepseek_api_key: str = ""
|
deepseek_api_key: str = ""
|
||||||
deepseek_base_url: str = "https://api.deepseek.com/v1"
|
deepseek_base_url: str = "https://api.deepseek.com/v1"
|
||||||
|
|||||||
Binary file not shown.
@ -57,12 +57,20 @@ class SectorInfo(BaseModel):
|
|||||||
sector_code: str
|
sector_code: str
|
||||||
sector_name: str
|
sector_name: str
|
||||||
pct_change: float = 0 # 涨跌幅 %
|
pct_change: float = 0 # 涨跌幅 %
|
||||||
capital_inflow: float = 0 # 主力净流入(万)
|
capital_inflow: float = 0 # 主力净流入(万元,原始数据来自Tushare亿元×10000)
|
||||||
limit_up_count: int = 0 # 涨停数
|
limit_up_count: int = 0 # 涨停数
|
||||||
days_continuous: int = 0 # 连续资金流入天数
|
days_continuous: int = 0 # 连续资金流入天数
|
||||||
heat_score: float = 0 # 热度综合评分
|
heat_score: float = 0 # 热度综合评分
|
||||||
stage: str = "mid" # 板块阶段: early/mid/late/end
|
stage: str = "mid" # 板块阶段: early/mid/late/end
|
||||||
|
|
||||||
|
# ── 板块分析增强字段 ──
|
||||||
|
member_count: int = 0 # 成分股数量
|
||||||
|
leading_stocks: list[dict] = [] # 领涨股 [{ts_code, name, pct_chg, amount}]
|
||||||
|
capital_trend: list[float] = [] # 近5日资金净流入趋势(万)
|
||||||
|
pct_trend: list[float] = [] # 近5日涨跌幅趋势
|
||||||
|
turnover_avg: float = 0 # 板块平均换手率
|
||||||
|
main_force_ratio: float = 0 # 主力资金占比(主力净流入/总成交额)
|
||||||
|
|
||||||
|
|
||||||
class MarketTemperature(BaseModel):
|
class MarketTemperature(BaseModel):
|
||||||
trade_date: str
|
trade_date: str
|
||||||
@ -119,7 +127,8 @@ class Recommendation(BaseModel):
|
|||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
risk_note: str = ""
|
risk_note: str = ""
|
||||||
level: str = "" # 强烈推荐/推荐/观望/回避
|
level: str = "" # 强烈推荐/推荐/观望/回避
|
||||||
strategy: str = "momentum" # momentum(强中选强) / potential(潜在启动)
|
strategy: str = "trend_breakout" # trend_breakout / momentum(旧) / potential(旧)
|
||||||
|
entry_signal_type: str = "none" # breakout / pullback / launch / none
|
||||||
llm_analysis: str = "" # LLM 深度分析
|
llm_analysis: str = "" # LLM 深度分析
|
||||||
llm_score: float | None = None # AI 评分 1-10
|
llm_score: float | None = None # AI 评分 1-10
|
||||||
scan_session: str = ""
|
scan_session: str = ""
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -33,6 +33,7 @@ async def init_db():
|
|||||||
"ALTER TABLE recommendations ADD COLUMN llm_score REAL",
|
"ALTER TABLE recommendations ADD COLUMN llm_score REAL",
|
||||||
"ALTER TABLE market_temperature ADD COLUMN max_streak INTEGER",
|
"ALTER TABLE market_temperature ADD COLUMN max_streak INTEGER",
|
||||||
"ALTER TABLE market_temperature ADD COLUMN broken_rate REAL",
|
"ALTER TABLE market_temperature ADD COLUMN broken_rate REAL",
|
||||||
|
"ALTER TABLE recommendations ADD COLUMN entry_signal_type TEXT DEFAULT 'none'",
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
|
|||||||
@ -26,7 +26,8 @@ recommendations_table = Table(
|
|||||||
Column("stop_loss", Float),
|
Column("stop_loss", Float),
|
||||||
Column("reasons", Text),
|
Column("reasons", Text),
|
||||||
Column("llm_analysis", Text, default=""),
|
Column("llm_analysis", Text, default=""),
|
||||||
Column("strategy", Text, default="momentum"),
|
Column("strategy", Text, default="trend_breakout"),
|
||||||
|
Column("entry_signal_type", Text, default="none"),
|
||||||
Column("llm_score", Float, default=None),
|
Column("llm_score", Float, default=None),
|
||||||
Column("scan_session", Text),
|
Column("scan_session", Text),
|
||||||
Column("created_at", DateTime, server_default=func.now()),
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -60,17 +60,98 @@ async def get_latest_sectors() -> list[SectorInfo]:
|
|||||||
|
|
||||||
|
|
||||||
async def get_recommendation_history(days: int = 7) -> list[dict]:
|
async def get_recommendation_history(days: int = 7) -> list[dict]:
|
||||||
"""获取历史推荐记录"""
|
"""获取历史推荐记录,按日期分组返回"""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
import json
|
||||||
|
|
||||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
async with get_db() as db:
|
async with get_db() as db:
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# 查询所有历史推荐,按 ts_code 去重(每天取最新一条)
|
||||||
stmt = text(
|
stmt = text(
|
||||||
"SELECT * FROM recommendations WHERE created_at >= :start ORDER BY created_at DESC"
|
"SELECT * FROM recommendations "
|
||||||
|
"WHERE created_at >= :start "
|
||||||
|
"AND id IN ("
|
||||||
|
" SELECT MAX(id) FROM recommendations "
|
||||||
|
" WHERE created_at >= :start "
|
||||||
|
" GROUP BY date(created_at), ts_code"
|
||||||
|
") "
|
||||||
|
"ORDER BY created_at DESC, score DESC"
|
||||||
)
|
)
|
||||||
result = await db.execute(stmt, {"start": start})
|
result = await db.execute(stmt, {"start": start})
|
||||||
rows = result.fetchall()
|
rows = result.fetchall()
|
||||||
return [dict(row._mapping) for row in rows]
|
|
||||||
|
# 按日期分组
|
||||||
|
grouped: dict[str, list[dict]] = {}
|
||||||
|
for row in rows:
|
||||||
|
r = row._mapping
|
||||||
|
# SQLite created_at 是字符串 "YYYY-MM-DD HH:MM:SS"
|
||||||
|
ca = r["created_at"]
|
||||||
|
if ca:
|
||||||
|
date_str = str(ca)[:10] # 取前10字符即日期部分
|
||||||
|
created_at_str = str(ca)
|
||||||
|
else:
|
||||||
|
date_str = "unknown"
|
||||||
|
created_at_str = None
|
||||||
|
|
||||||
|
rec_dict = {
|
||||||
|
"ts_code": r["ts_code"],
|
||||||
|
"name": r["name"],
|
||||||
|
"sector": r["sector"] or "",
|
||||||
|
"score": r["score"] or 0,
|
||||||
|
"level": _score_to_level_static(r["score"] or 0),
|
||||||
|
"signal": r["signal"] or "HOLD",
|
||||||
|
"market_temp_score": r["market_temp_score"] or 0,
|
||||||
|
"sector_score": r["sector_score"] or 0,
|
||||||
|
"capital_score": r["capital_score"] or 0,
|
||||||
|
"technical_score": r["technical_score"] or 0,
|
||||||
|
"position_score": r.get("position_score") or 50,
|
||||||
|
"valuation_score": r.get("valuation_score") or 50,
|
||||||
|
"entry_price": r["entry_price"],
|
||||||
|
"target_price": r["target_price"],
|
||||||
|
"stop_loss": r["stop_loss"],
|
||||||
|
"reasons": json.loads(r["reasons"]) if r["reasons"] else [],
|
||||||
|
"risk_note": "",
|
||||||
|
"strategy": r.get("strategy") or "trend_breakout",
|
||||||
|
"entry_signal_type": r.get("entry_signal_type") or "none",
|
||||||
|
"llm_analysis": r.get("llm_analysis") or "",
|
||||||
|
"llm_score": r.get("llm_score"),
|
||||||
|
"scan_session": r["scan_session"] or "",
|
||||||
|
"created_at": created_at_str,
|
||||||
|
}
|
||||||
|
|
||||||
|
if date_str not in grouped:
|
||||||
|
grouped[date_str] = []
|
||||||
|
grouped[date_str].append(rec_dict)
|
||||||
|
|
||||||
|
# 转为列表,按日期降序
|
||||||
|
result_list = []
|
||||||
|
for date_str in sorted(grouped.keys(), reverse=True):
|
||||||
|
recs = grouped[date_str]
|
||||||
|
buy_count = sum(1 for r in recs if r["signal"] == "BUY")
|
||||||
|
avg_score = round(sum(r["score"] for r in recs) / len(recs), 1) if recs else 0
|
||||||
|
result_list.append({
|
||||||
|
"date": date_str,
|
||||||
|
"count": len(recs),
|
||||||
|
"buy_count": buy_count,
|
||||||
|
"avg_score": avg_score,
|
||||||
|
"recommendations": recs,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result_list
|
||||||
|
|
||||||
|
|
||||||
|
def _score_to_level_static(score: float) -> str:
|
||||||
|
"""根据评分确定推荐等级"""
|
||||||
|
if score >= 75:
|
||||||
|
return "强烈推荐"
|
||||||
|
elif score >= 60:
|
||||||
|
return "推荐"
|
||||||
|
elif score >= 45:
|
||||||
|
return "关注"
|
||||||
|
else:
|
||||||
|
return "观望"
|
||||||
|
|
||||||
|
|
||||||
async def _save_to_db(result: dict):
|
async def _save_to_db(result: dict):
|
||||||
@ -118,7 +199,12 @@ async def _save_to_db(result: dict):
|
|||||||
)
|
)
|
||||||
await db.execute(stmt)
|
await db.execute(stmt)
|
||||||
|
|
||||||
# 保存推荐
|
# 保存推荐(先清除今日旧推荐,避免重复)
|
||||||
|
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
await db.execute(
|
||||||
|
text("DELETE FROM recommendations WHERE date(created_at) = :today"),
|
||||||
|
{"today": today_str},
|
||||||
|
)
|
||||||
import json
|
import json
|
||||||
for rec in result.get("recommendations", []):
|
for rec in result.get("recommendations", []):
|
||||||
stmt = tables.recommendations_table.insert().values(
|
stmt = tables.recommendations_table.insert().values(
|
||||||
@ -139,6 +225,7 @@ async def _save_to_db(result: dict):
|
|||||||
reasons=json.dumps(rec.reasons, ensure_ascii=False),
|
reasons=json.dumps(rec.reasons, ensure_ascii=False),
|
||||||
llm_analysis=rec.llm_analysis,
|
llm_analysis=rec.llm_analysis,
|
||||||
strategy=rec.strategy,
|
strategy=rec.strategy,
|
||||||
|
entry_signal_type=rec.entry_signal_type,
|
||||||
llm_score=rec.llm_score,
|
llm_score=rec.llm_score,
|
||||||
scan_session=rec.scan_session,
|
scan_session=rec.scan_session,
|
||||||
)
|
)
|
||||||
@ -177,9 +264,11 @@ async def _load_today_from_db() -> dict:
|
|||||||
temperature=m["temperature"],
|
temperature=m["temperature"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 加载推荐
|
# 加载推荐(按 ts_code 去重,取最新一条)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text("SELECT * FROM recommendations WHERE date(created_at) = :today ORDER BY score DESC"),
|
text("SELECT * FROM recommendations WHERE date(created_at) = :today "
|
||||||
|
"AND id IN (SELECT MAX(id) FROM recommendations WHERE date(created_at) = :today GROUP BY ts_code) "
|
||||||
|
"ORDER BY score DESC"),
|
||||||
{"today": today}
|
{"today": today}
|
||||||
)
|
)
|
||||||
rows = result.fetchall()
|
rows = result.fetchall()
|
||||||
@ -203,7 +292,8 @@ async def _load_today_from_db() -> dict:
|
|||||||
stop_loss=r["stop_loss"],
|
stop_loss=r["stop_loss"],
|
||||||
reasons=json.loads(r["reasons"]) if r["reasons"] else [],
|
reasons=json.loads(r["reasons"]) if r["reasons"] else [],
|
||||||
llm_analysis=r.get("llm_analysis") or "",
|
llm_analysis=r.get("llm_analysis") or "",
|
||||||
strategy=r.get("strategy") or "momentum",
|
strategy=r.get("strategy") or "trend_breakout",
|
||||||
|
entry_signal_type=r.get("entry_signal_type") or "none",
|
||||||
llm_score=r.get("llm_score"),
|
llm_score=r.get("llm_score"),
|
||||||
scan_session=r["scan_session"] or "",
|
scan_session=r["scan_session"] or "",
|
||||||
))
|
))
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"""双通道漏斗筛选器
|
"""趋势突破统一筛选器
|
||||||
|
|
||||||
Channel A(强中选强):市场温度 → 板块热度 → 资金筛选 → 技术信号
|
三阶段管道:全市场批量预筛 → 资金流过滤 → 逐股深度分析
|
||||||
Channel B(潜在启动):全市场技术扫描 → 底部形态 → 估值筛选
|
评分公式:趋势&时机30% + 资金流25% + 供需20% + 板块共振15% + 市场温度10%
|
||||||
|
|
||||||
自动检测是否在交易时段:
|
自动检测是否在交易时段:
|
||||||
- 盘中模式:用前一日 Tushare 数据 + 腾讯实时行情混合筛选
|
- 盘中模式:用前一日 Tushare 数据 + 腾讯实时行情混合筛选
|
||||||
@ -12,22 +12,17 @@ import logging
|
|||||||
|
|
||||||
from app.analysis.market_temp import calculate_market_temperature
|
from app.analysis.market_temp import calculate_market_temperature
|
||||||
from app.analysis.sector_scanner import scan_hot_sectors
|
from app.analysis.sector_scanner import scan_hot_sectors
|
||||||
from app.analysis.capital_flow import filter_stocks_by_capital
|
from app.analysis.trend_scanner import scan_trend_breakout
|
||||||
from app.analysis.potential_scanner import scan_potential_breakout
|
|
||||||
from app.analysis.signals import generate_signals
|
from app.analysis.signals import generate_signals
|
||||||
from app.analysis.intraday import intraday_market_temperature, intraday_filter_stocks
|
from app.analysis.intraday import intraday_market_temperature, intraday_filter_stocks, intraday_sector_scan
|
||||||
from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Recommendation
|
from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Recommendation
|
||||||
from app.config import settings, is_trading_hours
|
from app.config import settings, is_trading_hours, is_market_session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def run_screening(trade_date: str = None) -> dict:
|
async def run_screening(trade_date: str = None) -> dict:
|
||||||
"""执行双通道筛选流程
|
"""执行趋势突破筛选流程
|
||||||
|
|
||||||
自动检测交易时段:
|
|
||||||
- 盘中 → 用前一日板块+实时行情筛选
|
|
||||||
- 盘后 → 用当日完整数据筛选
|
|
||||||
|
|
||||||
返回: {
|
返回: {
|
||||||
"market_temp": MarketTemperature,
|
"market_temp": MarketTemperature,
|
||||||
@ -36,11 +31,11 @@ async def run_screening(trade_date: str = None) -> dict:
|
|||||||
"scan_mode": "intraday" | "post_market",
|
"scan_mode": "intraday" | "post_market",
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
intraday = is_trading_hours()
|
intraday = is_market_session()
|
||||||
scan_mode = "intraday" if intraday else "post_market"
|
scan_mode = "intraday" if intraday else "post_market"
|
||||||
logger.info(f"=== 筛选模式: {'盘中实时' if intraday else '盘后'} ===")
|
logger.info(f"=== 筛选模式: {'盘中实时' if intraday else '盘后'} ===")
|
||||||
|
|
||||||
# ── 市场温度(共享) ──
|
# ── 市场温度 ──
|
||||||
logger.info("=== 市场温度计 ===")
|
logger.info("=== 市场温度计 ===")
|
||||||
market_temp = calculate_market_temperature(trade_date)
|
market_temp = calculate_market_temperature(trade_date)
|
||||||
|
|
||||||
@ -52,151 +47,147 @@ async def run_screening(trade_date: str = None) -> dict:
|
|||||||
|
|
||||||
market_temp_score = market_temp.temperature
|
market_temp_score = market_temp.temperature
|
||||||
|
|
||||||
# ── 板块热度(Channel A 需要) ──
|
# ── 板块热度(用于板块共振评分) ──
|
||||||
logger.info("=== 板块热度扫描 ===")
|
logger.info("=== 板块热度扫描 ===")
|
||||||
all_sectors = scan_hot_sectors(trade_date)
|
all_sectors = scan_hot_sectors(trade_date)
|
||||||
hot_sectors = all_sectors[:settings.top_sector_count]
|
hot_sectors = all_sectors[:settings.top_sector_count]
|
||||||
|
|
||||||
# ── Channel A:强中选强 ──
|
# 盘中用实时行情更新板块涨幅和涨停数
|
||||||
recommendations_a = []
|
if intraday:
|
||||||
capital_filtered = []
|
hot_sectors = await intraday_sector_scan(hot_sectors)
|
||||||
|
|
||||||
if hot_sectors:
|
# ── 趋势突破三阶段管道 ──
|
||||||
if intraday:
|
logger.info("=== 趋势突破扫描 ===")
|
||||||
logger.info("=== Channel A:盘中实时个股筛选 ===")
|
candidates = await scan_trend_breakout(
|
||||||
capital_filtered = await intraday_filter_stocks(hot_sectors)
|
trade_date=trade_date,
|
||||||
else:
|
market_temp=market_temp,
|
||||||
logger.info("=== Channel A:个股资金筛选 ===")
|
hot_sectors=hot_sectors,
|
||||||
capital_filtered = await filter_stocks_by_capital(hot_sectors, trade_date)
|
intraday=intraday,
|
||||||
|
)
|
||||||
|
|
||||||
if capital_filtered:
|
if not candidates:
|
||||||
recommendations_a = _build_recommendations(
|
logger.info("=== 筛选完成: 0 只股票 ===")
|
||||||
capital_filtered, market_temp, hot_sectors,
|
return {
|
||||||
market_temp_score=market_temp_score,
|
"market_temp": market_temp,
|
||||||
strategy="momentum", intraday=intraday,
|
"hot_sectors": hot_sectors,
|
||||||
)
|
"recommendations": [],
|
||||||
|
"scan_mode": scan_mode,
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(f"Channel A(强中选强): {len(recommendations_a)} 只")
|
# ── 构建推荐列表 ──
|
||||||
|
recommendations = _build_trend_recommendations(
|
||||||
|
candidates, market_temp, hot_sectors, market_temp_score, intraday,
|
||||||
|
)
|
||||||
|
|
||||||
# ── Channel B:潜在启动 ──
|
# 过滤低质量推荐
|
||||||
logger.info("=== Channel B:潜在启动扫描 ===")
|
recommendations = [r for r in recommendations if r.score >= 40]
|
||||||
exclude_codes = {r.ts_code for r in recommendations_a}
|
|
||||||
potential_filtered = scan_potential_breakout(trade_date, exclude_codes)
|
|
||||||
|
|
||||||
recommendations_b = []
|
logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===")
|
||||||
if potential_filtered:
|
for r in recommendations[:5]:
|
||||||
recommendations_b = _build_recommendations(
|
signal_map = {"breakout": "突破型", "pullback": "回踩型", "launch": "启动型"}
|
||||||
potential_filtered, market_temp, hot_sectors,
|
signal_label = signal_map.get(r.entry_signal_type, r.entry_signal_type)
|
||||||
market_temp_score=market_temp_score,
|
logger.info(f" [{signal_label}] {r.name}({r.ts_code}) {r.level} 评分={r.score} 信号={r.signal}")
|
||||||
strategy="potential", intraday=intraday,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Channel B(潜在启动): {len(recommendations_b)} 只")
|
|
||||||
|
|
||||||
# 合并,按评分排序
|
|
||||||
all_recommendations = recommendations_a + recommendations_b
|
|
||||||
all_recommendations.sort(key=lambda x: x.score, reverse=True)
|
|
||||||
|
|
||||||
# 过滤掉低质量推荐
|
|
||||||
all_recommendations = [r for r in all_recommendations if r.score >= 40]
|
|
||||||
|
|
||||||
logger.info(f"=== 筛选完成: {len(all_recommendations)} 只股票 ({scan_mode}) ===")
|
|
||||||
for r in all_recommendations[:5]:
|
|
||||||
strategy_label = "强中选强" if r.strategy == "momentum" else "潜在启动"
|
|
||||||
logger.info(f" [{strategy_label}] {r.name}({r.ts_code}) {r.level} 评分={r.score} 信号={r.signal}")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"market_temp": market_temp,
|
"market_temp": market_temp,
|
||||||
"hot_sectors": hot_sectors,
|
"hot_sectors": hot_sectors,
|
||||||
"capital_filtered": capital_filtered,
|
"recommendations": recommendations,
|
||||||
"recommendations": all_recommendations,
|
|
||||||
"scan_mode": scan_mode,
|
"scan_mode": scan_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_recommendations(
|
def _build_trend_recommendations(
|
||||||
stocks: list[dict],
|
candidates: list[dict],
|
||||||
market_temp: MarketTemperature,
|
market_temp: MarketTemperature,
|
||||||
hot_sectors: list[SectorInfo],
|
hot_sectors: list[SectorInfo],
|
||||||
market_temp_score: float = 0,
|
market_temp_score: float = 0,
|
||||||
strategy: str = "momentum",
|
|
||||||
intraday: bool = False,
|
intraday: bool = False,
|
||||||
) -> list[Recommendation]:
|
) -> list[Recommendation]:
|
||||||
"""从筛选结果构建推荐列表(Channel A/B 共用)"""
|
"""从趋势突破扫描结果构建推荐列表
|
||||||
|
|
||||||
|
评分公式:趋势&时机30% + 资金流25% + 供需20% + 板块共振15% + 市场温度10%
|
||||||
|
"""
|
||||||
recommendations = []
|
recommendations = []
|
||||||
|
|
||||||
for stock in stocks:
|
for stock in candidates:
|
||||||
ts_code = stock["ts_code"]
|
ts_code = stock["ts_code"]
|
||||||
name = stock["name"]
|
name = stock["name"]
|
||||||
sector = stock["sector"]
|
sector = stock["sector"]
|
||||||
|
entry_signal_type = stock.get("entry_signal_type", "none")
|
||||||
|
entry_signal_score = stock.get("entry_signal_score", 0)
|
||||||
|
tech_signal = stock.get("tech_signal")
|
||||||
|
|
||||||
tech_signal = generate_signals(ts_code, name)
|
# 各维度得分
|
||||||
|
trend_timing_score = stock.get("trend_timing_score", 50)
|
||||||
# 板块得分
|
supply_demand_score = stock.get("supply_demand_score", 50)
|
||||||
if strategy == "momentum":
|
capital_score = stock.get("capital_score", 50)
|
||||||
sector_score = _get_sector_score(sector, hot_sectors)
|
position_score = stock.get("position_score", 50)
|
||||||
sector_stage = _get_sector_stage(sector, hot_sectors)
|
|
||||||
else:
|
|
||||||
# Channel B:不在热门板块中,给基础分
|
|
||||||
sector_score = 30.0
|
|
||||||
sector_stage = "mid"
|
|
||||||
|
|
||||||
# 估值安全得分
|
|
||||||
valuation_score = stock.get("valuation_score", 50)
|
valuation_score = stock.get("valuation_score", 50)
|
||||||
|
|
||||||
# 位置安全得分
|
# 板块共振评分
|
||||||
position_score = tech_signal.position_score
|
sector_score = _score_sector_resonance(sector, hot_sectors)
|
||||||
|
sector_stage = _get_sector_stage(sector, hot_sectors)
|
||||||
|
|
||||||
# 综合评分(根据策略调整权重)
|
# 综合评分(新权重)
|
||||||
if strategy == "momentum":
|
final_score = (
|
||||||
# 强中选强:板块和资金权重高
|
trend_timing_score * 0.30 +
|
||||||
# 市场10% + 板块20% + 资金20% + 技术15% + 位置安全15% + 估值安全20%
|
capital_score * 0.25 +
|
||||||
final_score = (
|
supply_demand_score * 0.20 +
|
||||||
market_temp_score * 0.10 +
|
sector_score * 0.15 +
|
||||||
sector_score * 0.20 +
|
market_temp_score * 0.10
|
||||||
stock["capital_score"] * 0.20 +
|
)
|
||||||
tech_signal.score * 0.15 +
|
|
||||||
position_score * 0.15 +
|
|
||||||
valuation_score * 0.20
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# 潜在启动:技术面和估值权重高
|
|
||||||
# 市场10% + 技术25% + 资金(potential_score)15% + 位置安全25% + 估值安全25%
|
|
||||||
final_score = (
|
|
||||||
market_temp_score * 0.10 +
|
|
||||||
tech_signal.score * 0.25 +
|
|
||||||
stock["capital_score"] * 0.15 +
|
|
||||||
position_score * 0.25 +
|
|
||||||
valuation_score * 0.25
|
|
||||||
)
|
|
||||||
|
|
||||||
# 板块尾声阶段额外惩罚(仅 Channel A)
|
# 风险乘数
|
||||||
if strategy == "momentum":
|
if tech_signal:
|
||||||
if sector_stage == "end":
|
if tech_signal.rally_pct_5d > 20:
|
||||||
final_score *= 0.85
|
final_score *= 0.65
|
||||||
elif sector_stage == "late":
|
elif tech_signal.rally_pct_5d > 15:
|
||||||
final_score *= 0.92
|
final_score *= 0.80
|
||||||
|
|
||||||
|
if sector_stage == "end":
|
||||||
|
final_score *= 0.70
|
||||||
|
elif sector_stage == "late":
|
||||||
|
final_score *= 0.88
|
||||||
|
|
||||||
|
if market_temp_score < 30:
|
||||||
|
final_score *= 0.75
|
||||||
|
|
||||||
|
# 入场信号高置信度奖励
|
||||||
|
if entry_signal_score >= 80:
|
||||||
|
final_score *= 1.10
|
||||||
|
|
||||||
# 确定信号和等级
|
# 确定信号和等级
|
||||||
signal = "HOLD"
|
|
||||||
if strategy == "momentum":
|
|
||||||
if (tech_signal.score >= settings.buy_score_threshold
|
|
||||||
and tech_signal.signal_count >= settings.buy_min_signals
|
|
||||||
and position_score >= 30):
|
|
||||||
signal = "BUY"
|
|
||||||
else:
|
|
||||||
# Channel B:技术面要求稍低,但位置安全要求更高
|
|
||||||
if (tech_signal.score >= settings.buy_score_threshold * 0.7
|
|
||||||
and position_score >= 40):
|
|
||||||
signal = "BUY"
|
|
||||||
|
|
||||||
level = _score_to_level(final_score)
|
level = _score_to_level(final_score)
|
||||||
|
signal = "HOLD"
|
||||||
|
if entry_signal_type != "none" and entry_signal_score >= 50 and position_score >= 30 and final_score >= 60:
|
||||||
|
signal = "BUY"
|
||||||
|
|
||||||
|
# 价格参考
|
||||||
|
entry_price = None
|
||||||
|
target_price = None
|
||||||
|
stop_loss = None
|
||||||
|
if tech_signal:
|
||||||
|
entry_price = tech_signal.support_price
|
||||||
|
target_price = tech_signal.resist_price
|
||||||
|
stop_loss = tech_signal.stop_loss_price
|
||||||
|
|
||||||
|
# 根据入场信号类型调整参考价
|
||||||
|
details = stock.get("entry_signal_details", {})
|
||||||
|
if entry_signal_type == "breakout" and details.get("resist_level"):
|
||||||
|
entry_price = details["resist_level"]
|
||||||
|
target_price = round(entry_price * 1.05, 2)
|
||||||
|
elif entry_signal_type == "pullback" and details.get("support_price"):
|
||||||
|
entry_price = details["support_price"]
|
||||||
|
target_price = round(entry_price * 1.05, 2)
|
||||||
|
elif entry_signal_type == "launch" and details.get("resist_level"):
|
||||||
|
entry_price = round(details["resist_level"] * 1.01, 2)
|
||||||
|
target_price = round(details["resist_level"] * 1.08, 2)
|
||||||
|
|
||||||
# 生成推荐理由
|
# 生成推荐理由
|
||||||
reasons = _generate_reasons(stock, tech_signal, market_temp, intraday, strategy)
|
reasons = _generate_reasons(stock, tech_signal, market_temp, intraday)
|
||||||
|
|
||||||
# 风险提示
|
# 风险提示
|
||||||
risk_note = _generate_risk_note(market_temp, tech_signal, intraday, strategy)
|
risk_note = _generate_risk_note(market_temp, tech_signal, stock)
|
||||||
|
|
||||||
rec = Recommendation(
|
rec = Recommendation(
|
||||||
ts_code=ts_code,
|
ts_code=ts_code,
|
||||||
@ -205,24 +196,41 @@ def _build_recommendations(
|
|||||||
score=round(final_score, 1),
|
score=round(final_score, 1),
|
||||||
market_temp_score=round(market_temp_score, 1),
|
market_temp_score=round(market_temp_score, 1),
|
||||||
sector_score=round(sector_score, 1),
|
sector_score=round(sector_score, 1),
|
||||||
capital_score=round(stock["capital_score"], 1),
|
capital_score=round(capital_score, 1),
|
||||||
technical_score=round(tech_signal.score, 1),
|
technical_score=round(stock.get("technical_score", 50), 1),
|
||||||
position_score=round(position_score, 1),
|
position_score=round(position_score, 1),
|
||||||
valuation_score=round(valuation_score, 1),
|
valuation_score=round(valuation_score, 1),
|
||||||
signal=signal,
|
signal=signal,
|
||||||
entry_price=tech_signal.support_price,
|
entry_price=entry_price,
|
||||||
target_price=tech_signal.resist_price,
|
target_price=target_price,
|
||||||
stop_loss=tech_signal.stop_loss_price,
|
stop_loss=stop_loss,
|
||||||
reasons=reasons,
|
reasons=reasons,
|
||||||
risk_note=risk_note,
|
risk_note=risk_note,
|
||||||
level=level,
|
level=level,
|
||||||
strategy=strategy,
|
strategy="trend_breakout",
|
||||||
|
entry_signal_type=entry_signal_type,
|
||||||
)
|
)
|
||||||
recommendations.append(rec)
|
recommendations.append(rec)
|
||||||
|
|
||||||
return recommendations
|
return recommendations
|
||||||
|
|
||||||
|
|
||||||
|
def _score_sector_resonance(sector_name: str, hot_sectors: list[SectorInfo]) -> float:
|
||||||
|
"""板块共振评分 (0-100)"""
|
||||||
|
for s in hot_sectors:
|
||||||
|
if s.sector_name == sector_name:
|
||||||
|
score = 40 # 在热门板块列表中
|
||||||
|
score += s.heat_score * 0.3 # 板块热度贡献
|
||||||
|
if s.stage == "early":
|
||||||
|
score += 30
|
||||||
|
elif s.stage == "mid":
|
||||||
|
score += 20
|
||||||
|
elif s.stage == "late":
|
||||||
|
score += 5
|
||||||
|
return min(score, 100)
|
||||||
|
return 10.0 # 不在热门板块
|
||||||
|
|
||||||
|
|
||||||
def _get_sector_score(sector_name: str, hot_sectors: list[SectorInfo]) -> float:
|
def _get_sector_score(sector_name: str, hot_sectors: list[SectorInfo]) -> float:
|
||||||
"""获取板块在热门板块中的得分"""
|
"""获取板块在热门板块中的得分"""
|
||||||
for s in hot_sectors:
|
for s in hot_sectors:
|
||||||
@ -251,99 +259,93 @@ def _score_to_level(score: float) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _generate_reasons(
|
def _generate_reasons(
|
||||||
stock: dict, tech: TechnicalSignal, market: MarketTemperature,
|
stock: dict, tech: TechnicalSignal | None,
|
||||||
intraday: bool = False, strategy: str = "momentum",
|
market: MarketTemperature, intraday: bool = False,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
"""生成推荐理由"""
|
||||||
reasons = []
|
reasons = []
|
||||||
|
entry_type = stock.get("entry_signal_type", "none")
|
||||||
|
signal_map = {"breakout": "突破型", "pullback": "回踩型", "launch": "启动型"}
|
||||||
|
entry_label = signal_map.get(entry_type, "")
|
||||||
|
|
||||||
if strategy == "potential":
|
# 入场信号
|
||||||
# Channel B 理由:侧重底部形态和估值
|
if entry_label:
|
||||||
if tech.position_score >= 70:
|
details = stock.get("entry_signal_details", {})
|
||||||
reasons.append("位置处于低位,距高点回调充分")
|
if entry_type == "breakout":
|
||||||
if tech.macd_golden:
|
breakout_pct = details.get("breakout_pct", 0)
|
||||||
reasons.append("MACD底部金叉,反转信号初现")
|
vol_ratio = details.get("volume_ratio", 0)
|
||||||
if tech.pullback_support:
|
reasons.append(f"放量突破20日阻力位(涨幅{breakout_pct:.1f}%,量比{vol_ratio:.1f}倍)")
|
||||||
reasons.append("缩量回踩支撑位,蓄势充分")
|
elif entry_type == "pullback":
|
||||||
if tech.big_yang:
|
support = details.get("support_ma", "")
|
||||||
reasons.append("底部出现放量长阳,资金介入")
|
shrink = details.get("volume_shrink_ratio", 0)
|
||||||
if stock.get("valuation_score", 0) >= 60:
|
reasons.append(f"缩量回踩{support}支撑(量能收缩至{shrink:.0%})")
|
||||||
reasons.append("估值安全,下行空间有限")
|
elif entry_type == "launch":
|
||||||
if not reasons:
|
range_pct = details.get("price_range_pct", 0)
|
||||||
reasons.append("技术面底部信号显现,关注启动时机")
|
shrink = details.get("volume_shrink_ratio", 0)
|
||||||
elif intraday:
|
reasons.append(f"高位缩量整理{range_pct:.1f}%后即将变盘(量缩至{shrink:.0%})")
|
||||||
# Channel A 盘中理由
|
|
||||||
pct = stock.get("pct_chg", 0)
|
|
||||||
vr = stock.get("volume_ratio")
|
|
||||||
if vr and vr > 2:
|
|
||||||
reasons.append(f"盘中量比{vr:.1f}倍,资金活跃度高")
|
|
||||||
if pct > 3:
|
|
||||||
reasons.append(f"盘中涨幅{pct:.1f}%,走势强劲")
|
|
||||||
elif pct > 0:
|
|
||||||
reasons.append(f"盘中涨幅{pct:.1f}%,温和上攻")
|
|
||||||
|
|
||||||
reasons.append(f"所属板块【{stock['sector']}】为当前热门概念")
|
# 供需分析
|
||||||
|
vol_trend = stock.get("volume_trend", "")
|
||||||
|
ds_ratio = stock.get("demand_supply_ratio", 1)
|
||||||
|
if ds_ratio > 1.5:
|
||||||
|
reasons.append(f"需求主导(上涨均量/下跌均量={ds_ratio:.1f})")
|
||||||
|
elif vol_trend == "expanding":
|
||||||
|
reasons.append("量能逐步放大,资金持续介入")
|
||||||
|
|
||||||
|
# 资金流
|
||||||
|
main_net = stock.get("main_net_inflow", 0)
|
||||||
|
if main_net > 5000:
|
||||||
|
reasons.append(f"主力资金大幅流入{main_net:.0f}万元")
|
||||||
|
elif main_net > 1000:
|
||||||
|
reasons.append(f"主力资金持续流入{main_net:.0f}万元")
|
||||||
|
|
||||||
|
# 板块
|
||||||
|
sector = stock.get("sector", "")
|
||||||
|
if sector:
|
||||||
|
reasons.append(f"所属板块【{sector}】")
|
||||||
|
|
||||||
|
# 技术面
|
||||||
|
if tech:
|
||||||
tech_reasons = []
|
tech_reasons = []
|
||||||
if tech.ma_bullish:
|
if tech.ma_bullish:
|
||||||
tech_reasons.append("均线多头排列")
|
tech_reasons.append("均线多头排列")
|
||||||
if tech.volume_breakout:
|
|
||||||
tech_reasons.append("放量突破")
|
|
||||||
if tech.macd_golden:
|
|
||||||
tech_reasons.append("MACD金叉")
|
|
||||||
if tech_reasons:
|
|
||||||
reasons.append("技术面: " + "、".join(tech_reasons))
|
|
||||||
else:
|
|
||||||
# Channel A 盘后理由
|
|
||||||
inflow = stock.get("main_net_inflow", 0)
|
|
||||||
if inflow > 5000:
|
|
||||||
reasons.append(f"主力资金大幅流入{inflow:.0f}万元")
|
|
||||||
elif inflow > 1000:
|
|
||||||
reasons.append(f"主力资金持续流入{inflow:.0f}万元")
|
|
||||||
|
|
||||||
reasons.append(f"所属板块【{stock['sector']}】为当前热门概念")
|
|
||||||
|
|
||||||
tech_reasons = []
|
|
||||||
if tech.ma_bullish:
|
|
||||||
tech_reasons.append("均线多头排列")
|
|
||||||
if tech.volume_breakout:
|
|
||||||
tech_reasons.append("放量突破")
|
|
||||||
if tech.macd_golden:
|
if tech.macd_golden:
|
||||||
tech_reasons.append("MACD金叉")
|
tech_reasons.append("MACD金叉")
|
||||||
if tech.pullback_support:
|
if tech.pullback_support:
|
||||||
tech_reasons.append("缩量回踩支撑")
|
tech_reasons.append("缩量回踩支撑")
|
||||||
if tech.big_yang:
|
|
||||||
tech_reasons.append("底部放量长阳")
|
|
||||||
if tech_reasons:
|
if tech_reasons:
|
||||||
reasons.append("技术面: " + "、".join(tech_reasons))
|
reasons.append("技术面: " + "、".join(tech_reasons))
|
||||||
|
|
||||||
# 位置安全
|
|
||||||
if tech.position_score >= 70:
|
|
||||||
reasons.append("位置安全,距高点有空间")
|
|
||||||
elif tech.position_score < 30:
|
|
||||||
reasons.append("注意:短期涨幅较大,追高风险")
|
|
||||||
|
|
||||||
return reasons[:3]
|
return reasons[:3]
|
||||||
|
|
||||||
|
|
||||||
def _generate_risk_note(
|
def _generate_risk_note(
|
||||||
market: MarketTemperature, tech: TechnicalSignal,
|
market: MarketTemperature,
|
||||||
intraday: bool = False, strategy: str = "momentum",
|
tech: TechnicalSignal | None,
|
||||||
|
stock: dict,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
"""生成风险提示"""
|
||||||
notes = []
|
notes = []
|
||||||
if intraday:
|
entry_type = stock.get("entry_signal_type", "")
|
||||||
notes.append("盘中数据参考,需结合尾盘确认")
|
|
||||||
if strategy == "potential":
|
if entry_type == "breakout":
|
||||||
notes.append("底部股票可能继续盘整,注意时间成本")
|
notes.append("突破型需警惕假突破,关注量能是否持续")
|
||||||
|
elif entry_type == "pullback":
|
||||||
|
notes.append("回踩型可能继续下探支撑,注意止损纪律")
|
||||||
|
elif entry_type == "launch":
|
||||||
|
notes.append("启动型整理可能延长,注意时间成本")
|
||||||
|
|
||||||
if market.temperature < 30:
|
if market.temperature < 30:
|
||||||
notes.append("市场情绪偏冷,系统性风险较高")
|
notes.append("市场情绪偏冷,系统性风险较高")
|
||||||
elif market.temperature < 50:
|
elif market.temperature < 50:
|
||||||
notes.append("市场情绪一般,注意仓位控制")
|
notes.append("市场情绪一般,注意仓位控制")
|
||||||
if tech.score < 40:
|
|
||||||
notes.append("技术面支撑不足")
|
if tech:
|
||||||
if tech.position_score < 30:
|
if tech.position_score < 30:
|
||||||
notes.append(f"近期涨幅较大(5日{tech.rally_pct_5d}%),追高风险")
|
notes.append(f"近期涨幅较大(5日{tech.rally_pct_5d}%),追高风险")
|
||||||
if tech.rally_pct_10d > 20:
|
if tech.rally_pct_10d > 20:
|
||||||
notes.append(f"10日累涨{tech.rally_pct_10d}%,警惕回调")
|
notes.append(f"10日累涨{tech.rally_pct_10d}%,警惕回调")
|
||||||
|
|
||||||
if not notes:
|
if not notes:
|
||||||
return "注意设好止损,控制仓位"
|
return "注意设好止损,控制仓位"
|
||||||
return ";".join(notes)
|
return ";".join(notes)
|
||||||
|
|||||||
Binary file not shown.
@ -10,10 +10,7 @@ import logging
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from app.llm.client import chat_completion
|
from app.llm.client import chat_completion
|
||||||
from app.llm.prompts import (
|
from app.llm.prompts import TREND_BREAKOUT_ANALYSIS_PROMPT
|
||||||
MOMENTUM_ANALYSIS_PROMPT,
|
|
||||||
POTENTIAL_ANALYSIS_PROMPT,
|
|
||||||
)
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -62,16 +59,15 @@ async def _do_analyze(result: dict, recommendations: list) -> None:
|
|||||||
# 预先获取该股票的详细数据
|
# 预先获取该股票的详细数据
|
||||||
stock_data = await _fetch_stock_data(rec.ts_code, rec.sector)
|
stock_data = await _fetch_stock_data(rec.ts_code, rec.sector)
|
||||||
|
|
||||||
strategy_label = "强中选强" if rec.strategy == "momentum" else "潜在启动"
|
strategy_label = "趋势突破"
|
||||||
system_prompt = (
|
signal_map = {"breakout": "突破型", "pullback": "回踩型", "launch": "启动型", "none": "无信号"}
|
||||||
MOMENTUM_ANALYSIS_PROMPT
|
entry_label = signal_map.get(rec.entry_signal_type, "无信号")
|
||||||
if rec.strategy == "momentum"
|
system_prompt = TREND_BREAKOUT_ANALYSIS_PROMPT
|
||||||
else POTENTIAL_ANALYSIS_PROMPT
|
|
||||||
)
|
|
||||||
|
|
||||||
user_msg = _build_user_message(
|
user_msg = _build_user_message(
|
||||||
rec=rec,
|
rec=rec,
|
||||||
strategy_label=strategy_label,
|
strategy_label=strategy_label,
|
||||||
|
entry_label=entry_label,
|
||||||
market_temp=market_temp,
|
market_temp=market_temp,
|
||||||
temp_level=temp_level,
|
temp_level=temp_level,
|
||||||
sectors_text=sectors_text,
|
sectors_text=sectors_text,
|
||||||
@ -169,6 +165,7 @@ async def _fetch_stock_data(ts_code: str, sector: str) -> str:
|
|||||||
def _build_user_message(
|
def _build_user_message(
|
||||||
rec,
|
rec,
|
||||||
strategy_label: str,
|
strategy_label: str,
|
||||||
|
entry_label: str,
|
||||||
market_temp,
|
market_temp,
|
||||||
temp_level: str,
|
temp_level: str,
|
||||||
sectors_text: str,
|
sectors_text: str,
|
||||||
@ -179,6 +176,7 @@ def _build_user_message(
|
|||||||
- 股票: {rec.name}({rec.ts_code})
|
- 股票: {rec.name}({rec.ts_code})
|
||||||
- 所属板块: {rec.sector}
|
- 所属板块: {rec.sector}
|
||||||
- 策略类型: {strategy_label}
|
- 策略类型: {strategy_label}
|
||||||
|
- 入场信号: {entry_label}
|
||||||
- 综合评分: {rec.score}分({rec.level})
|
- 综合评分: {rec.score}分({rec.level})
|
||||||
- 各维度: 市场{rec.market_temp_score} | 板块{rec.sector_score} | 资金{rec.capital_score} | 技术{rec.technical_score} | 位置{rec.position_score} | 估值{rec.valuation_score}
|
- 各维度: 市场{rec.market_temp_score} | 板块{rec.sector_score} | 资金{rec.capital_score} | 技术{rec.technical_score} | 位置{rec.position_score} | 估值{rec.valuation_score}
|
||||||
- 信号: {rec.signal}
|
- 信号: {rec.signal}
|
||||||
|
|||||||
@ -54,63 +54,42 @@ CHAT_SYSTEM_PROMPT = """\
|
|||||||
|
|
||||||
# ── AI 分析 Agent Prompt ──
|
# ── AI 分析 Agent Prompt ──
|
||||||
|
|
||||||
MOMENTUM_ANALYSIS_PROMPT = """\
|
TREND_BREAKOUT_ANALYSIS_PROMPT = """\
|
||||||
你是一位专业的 A 股趋势交易分析师。你需要评估一只处于热门板块中的强势股是否值得追入。
|
你是一位专业的 A 股趋势突破交易分析师。你需要评估一只处于上升趋势中、即将突破的股票的入场时机。
|
||||||
|
|
||||||
系统已为你提供了该股票的量化评分、K线数据、资金流向、技术信号、板块数据等详细信息,请基于这些数据进行深度分析。
|
系统已为你提供了该股票的量化评分、K线数据、资金流向、技术信号、板块数据等详细信息,请基于这些数据进行深度分析。
|
||||||
|
|
||||||
重点关注:
|
重点关注:
|
||||||
1. 当前趋势的持续性:量价是否配合?资金流入是否持续?
|
1. 入场信号类型确认:突破型/回踩型/启动型,信号是否可靠?
|
||||||
2. 追入的安全性:当前位置高低?短期是否过热?
|
2. 量价配合:上涨放量、回调缩量的特征是否明显?
|
||||||
3. 入场时机:应该回调到支撑位买入,还是突破追入?
|
3. 资金持续性:主力资金是否持续流入3天以上?
|
||||||
4. 风险收益比:上行目标空间 vs 下行止损空间
|
4. 1-5日操作策略:最佳入场价位、目标价位(2-5%空间)、止损价位
|
||||||
|
5. 时机判断:预计突破/反弹在1-3天内发生的概率
|
||||||
|
|
||||||
请严格按以下格式输出分析报告:
|
请严格按以下格式输出分析报告:
|
||||||
|
|
||||||
### 核心逻辑
|
### 信号类型确认
|
||||||
(1-2句核心投资逻辑,说明为什么值得关注)
|
(突破型/回踩型/启动型,判断依据,可靠性评估)
|
||||||
|
|
||||||
### 趋势分析
|
### 量价分析
|
||||||
(均线排列、MACD状态、成交量变化趋势,用数据说话)
|
(成交量变化趋势、供需关系、资金介入程度)
|
||||||
|
|
||||||
### 入场策略
|
### 操作策略(1-5日)
|
||||||
(建议的入场价位和方式:回调买入/突破买入/分批建仓)
|
(入场价位、分批建仓计划、目标价位、止损价位)
|
||||||
|
|
||||||
|
### 时间窗口
|
||||||
|
(预计启动时间、关键观察节点)
|
||||||
|
|
||||||
### 风险提示
|
### 风险提示
|
||||||
(主要风险因素:板块衰退、大盘系统性风险、量能不济等)
|
(主要风险因素:假突破风险、板块衰退、大盘系统性风险等)
|
||||||
|
|
||||||
### AI 评分
|
### AI 评分
|
||||||
(给出 1-10 分,格式为纯数字,如:7)
|
(给出 1-10 分,格式为纯数字,如:8)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
POTENTIAL_ANALYSIS_PROMPT = """\
|
# 保留旧 prompt 用于向后兼容(旧推荐数据仍可能使用)
|
||||||
你是一位专业的 A 股底部反转交易分析师。你需要评估一只处于底部的股票是否具备启动条件。
|
MOMENTUM_ANALYSIS_PROMPT = TREND_BREAKOUT_ANALYSIS_PROMPT
|
||||||
|
POTENTIAL_ANALYSIS_PROMPT = TREND_BREAKOUT_ANALYSIS_PROMPT
|
||||||
系统已为你提供了该股票的量化评分、K线数据、资金流向、技术信号、板块数据等详细信息,请基于这些数据进行深度分析。
|
|
||||||
|
|
||||||
重点关注:
|
|
||||||
1. 底部信号的可信度:量价配合如何?多个技术指标是否共振?
|
|
||||||
2. 可能的催化剂:板块轮动机会?资金是否有流入迹象?技术面是否接近突破?
|
|
||||||
3. 时间窗口:底部蓄势了多久?均线是否收敛?何时可能启动?
|
|
||||||
4. 风险:继续下行的概率和幅度?是否有基本面隐患?
|
|
||||||
|
|
||||||
请严格按以下格式输出分析报告:
|
|
||||||
|
|
||||||
### 底部信号分析
|
|
||||||
(底部形态特征、量价变化、技术指标状态)
|
|
||||||
|
|
||||||
### 催化剂判断
|
|
||||||
(可能的上涨催化剂:板块轮动、资金流入、技术突破等)
|
|
||||||
|
|
||||||
### 埋伏策略
|
|
||||||
(建议的建仓方式和价位、仓位建议、等待确认信号)
|
|
||||||
|
|
||||||
### 风险提示
|
|
||||||
(主要风险因素:继续下行风险、底部无效风险、时间成本等)
|
|
||||||
|
|
||||||
### AI 评分
|
|
||||||
(给出 1-10 分,格式为纯数字,如:6)
|
|
||||||
"""
|
|
||||||
|
|
||||||
ANALYSIS_USER_TEMPLATE = """\
|
ANALYSIS_USER_TEMPLATE = """\
|
||||||
## 量化系统数据
|
## 量化系统数据
|
||||||
|
|||||||
Binary file not shown.
@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"pages": {
|
"pages": {
|
||||||
|
"/sectors/page": [
|
||||||
|
"static/chunks/webpack.js",
|
||||||
|
"static/chunks/main-app.js",
|
||||||
|
"static/chunks/app/sectors/page.js"
|
||||||
|
],
|
||||||
"/layout": [
|
"/layout": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
@ -11,10 +16,10 @@
|
|||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
"static/chunks/app/page.js"
|
"static/chunks/app/page.js"
|
||||||
],
|
],
|
||||||
"/stock/[code]/page": [
|
"/recommendations/page": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
"static/chunks/app/stock/[code]/page.js"
|
"static/chunks/app/recommendations/page.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2,9 +2,7 @@
|
|||||||
"polyfillFiles": [
|
"polyfillFiles": [
|
||||||
"static/chunks/polyfills.js"
|
"static/chunks/polyfills.js"
|
||||||
],
|
],
|
||||||
"devFiles": [
|
"devFiles": [],
|
||||||
"static/chunks/react-refresh.js"
|
|
||||||
],
|
|
||||||
"ampDevFiles": [],
|
"ampDevFiles": [],
|
||||||
"lowPriorityFiles": [
|
"lowPriorityFiles": [
|
||||||
"static/development/_buildManifest.js",
|
"static/development/_buildManifest.js",
|
||||||
@ -15,16 +13,7 @@
|
|||||||
"static/chunks/main-app.js"
|
"static/chunks/main-app.js"
|
||||||
],
|
],
|
||||||
"pages": {
|
"pages": {
|
||||||
"/_app": [
|
"/_app": []
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main.js",
|
|
||||||
"static/chunks/pages/_app.js"
|
|
||||||
],
|
|
||||||
"/_error": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main.js",
|
|
||||||
"static/chunks/pages/_error.js"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"ampFirstPages": []
|
"ampFirstPages": []
|
||||||
}
|
}
|
||||||
2
frontend/.next/cache/.tsbuildinfo
vendored
2
frontend/.next/cache/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
@ -1,20 +1 @@
|
|||||||
{
|
{}
|
||||||
"components/capital-flow.tsx -> echarts": {
|
|
||||||
"id": "components/capital-flow.tsx -> echarts",
|
|
||||||
"files": [
|
|
||||||
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"components/kline-chart.tsx -> echarts": {
|
|
||||||
"id": "components/kline-chart.tsx -> echarts",
|
|
||||||
"files": [
|
|
||||||
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"components/score-radar.tsx -> echarts": {
|
|
||||||
"id": "components/score-radar.tsx -> echarts",
|
|
||||||
"files": [
|
|
||||||
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"/stock/[code]/page": "app/stock/[code]/page.js",
|
"/recommendations/page": "app/recommendations/page.js",
|
||||||
"/page": "app/page.js"
|
"/page": "app/page.js",
|
||||||
|
"/sectors/page": "app/sectors/page.js"
|
||||||
}
|
}
|
||||||
@ -2,9 +2,7 @@ self.__BUILD_MANIFEST = {
|
|||||||
"polyfillFiles": [
|
"polyfillFiles": [
|
||||||
"static/chunks/polyfills.js"
|
"static/chunks/polyfills.js"
|
||||||
],
|
],
|
||||||
"devFiles": [
|
"devFiles": [],
|
||||||
"static/chunks/react-refresh.js"
|
|
||||||
],
|
|
||||||
"ampDevFiles": [],
|
"ampDevFiles": [],
|
||||||
"lowPriorityFiles": [],
|
"lowPriorityFiles": [],
|
||||||
"rootMainFiles": [
|
"rootMainFiles": [
|
||||||
@ -12,16 +10,7 @@ self.__BUILD_MANIFEST = {
|
|||||||
"static/chunks/main-app.js"
|
"static/chunks/main-app.js"
|
||||||
],
|
],
|
||||||
"pages": {
|
"pages": {
|
||||||
"/_app": [
|
"/_app": []
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main.js",
|
|
||||||
"static/chunks/pages/_app.js"
|
|
||||||
],
|
|
||||||
"/_error": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main.js",
|
|
||||||
"static/chunks/pages/_error.js"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"ampFirstPages": []
|
"ampFirstPages": []
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
self.__REACT_LOADABLE_MANIFEST="{\"components/capital-flow.tsx -> echarts\":{\"id\":\"components/capital-flow.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/kline-chart.tsx -> echarts\":{\"id\":\"components/kline-chart.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/score-radar.tsx -> echarts\":{\"id\":\"components/score-radar.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}"
|
self.__REACT_LOADABLE_MANIFEST="{}"
|
||||||
@ -1,5 +1 @@
|
|||||||
{
|
{}
|
||||||
"/_app": "pages/_app.js",
|
|
||||||
"/_error": "pages/_error.js",
|
|
||||||
"/_document": "pages/_document.js"
|
|
||||||
}
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"node": {},
|
"node": {},
|
||||||
"edge": {},
|
"edge": {},
|
||||||
"encryptionKey": "b90zeeYzMcSqNivimWn6T2PQQNxojiQrSeFlOxUaP4Q="
|
"encryptionKey": "IAps6Nn+QS9GVH+sOr6laVRGfUJrD0aLxGy9A/+XkIs="
|
||||||
}
|
}
|
||||||
@ -125,7 +125,7 @@
|
|||||||
/******/
|
/******/
|
||||||
/******/ /* webpack/runtime/getFullHash */
|
/******/ /* webpack/runtime/getFullHash */
|
||||||
/******/ (() => {
|
/******/ (() => {
|
||||||
/******/ __webpack_require__.h = () => ("64fc734a2bef9f33")
|
/******/ __webpack_require__.h = () => ("a035f49818643978")
|
||||||
/******/ })();
|
/******/ })();
|
||||||
/******/
|
/******/
|
||||||
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -7,6 +7,7 @@ import MarketTemp from "@/components/market-temp";
|
|||||||
import StockCard from "@/components/stock-card";
|
import StockCard from "@/components/stock-card";
|
||||||
import SectorHeatmap from "@/components/sector-heatmap";
|
import SectorHeatmap from "@/components/sector-heatmap";
|
||||||
import { useWebSocket } from "@/hooks/use-websocket";
|
import { useWebSocket } from "@/hooks/use-websocket";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
interface ScanStatus {
|
interface ScanStatus {
|
||||||
is_trading: boolean;
|
is_trading: boolean;
|
||||||
@ -15,6 +16,7 @@ interface ScanStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
const [data, setData] = useState<LatestResult | null>(null);
|
const [data, setData] = useState<LatestResult | null>(null);
|
||||||
const [sectors, setSectors] = useState<SectorData[]>([]);
|
const [sectors, setSectors] = useState<SectorData[]>([]);
|
||||||
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
|
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
|
||||||
@ -114,6 +116,7 @@ export default function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{user?.role === "admin" && (
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
@ -130,6 +133,7 @@ export default function DashboardPage() {
|
|||||||
"立即扫描"
|
"立即扫描"
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scan result toast */}
|
{/* Scan result toast */}
|
||||||
@ -167,7 +171,7 @@ export default function DashboardPage() {
|
|||||||
<div className="glass-card-static p-10 text-center">
|
<div className="glass-card-static p-10 text-center">
|
||||||
<div className="text-text-muted text-sm mb-1">暂无推荐</div>
|
<div className="text-text-muted text-sm mb-1">暂无推荐</div>
|
||||||
<div className="text-text-muted/60 text-xs">
|
<div className="text-text-muted/60 text-xs">
|
||||||
点击 {scanStatus?.is_trading ? "「盘中扫描」" : "「立即扫描」"} 开始分析
|
{user?.role === "admin" ? `点击 ${scanStatus?.is_trading ? "「盘中扫描」" : "「立即扫描」"} 开始分析` : "等待系统自动扫描更新"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -2,12 +2,26 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { fetchAPI, postAPI } from "@/lib/api";
|
import { fetchAPI, postAPI } from "@/lib/api";
|
||||||
import type { LatestResult } from "@/lib/api";
|
import type { DayGroup } from "@/lib/api";
|
||||||
import StockCard from "@/components/stock-card";
|
import StockCard from "@/components/stock-card";
|
||||||
import { useWebSocket } from "@/hooks/use-websocket";
|
import { useWebSocket } from "@/hooks/use-websocket";
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
if (dateStr === today.toISOString().slice(0, 10)) return "今日";
|
||||||
|
if (dateStr === yesterday.toISOString().slice(0, 10)) return "昨日";
|
||||||
|
|
||||||
|
const weekDays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||||
|
return `${d.getMonth() + 1}月${d.getDate()}日 ${weekDays[d.getDay()]}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RecommendationsPage() {
|
export default function RecommendationsPage() {
|
||||||
const [data, setData] = useState<LatestResult | null>(null);
|
const [dayGroups, setDayGroups] = useState<DayGroup[]>([]);
|
||||||
|
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
|
||||||
const [filter, setFilter] = useState<string>("all");
|
const [filter, setFilter] = useState<string>("all");
|
||||||
const [llmEnabled, setLlmEnabled] = useState(false);
|
const [llmEnabled, setLlmEnabled] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
@ -15,12 +29,22 @@ export default function RecommendationsPage() {
|
|||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [result, health] = await Promise.all([
|
const [history, health] = await Promise.all([
|
||||||
fetchAPI<LatestResult>("/api/recommendations/latest"),
|
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
||||||
fetchAPI<{ llm_enabled: boolean }>("/api/health"),
|
fetchAPI<{ llm_enabled: boolean }>("/api/health"),
|
||||||
]);
|
]);
|
||||||
setData(result);
|
setDayGroups(history);
|
||||||
setLlmEnabled(health.llm_enabled);
|
setLlmEnabled(health.llm_enabled);
|
||||||
|
|
||||||
|
// 默认展开今天
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
setExpandedDays((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (!next.size && history.length > 0) {
|
||||||
|
next.add(history[0].date);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("加载推荐失败:", e);
|
console.error("加载推荐失败:", e);
|
||||||
}
|
}
|
||||||
@ -59,17 +83,26 @@ export default function RecommendationsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const recs = data?.recommendations ?? [];
|
const toggleDay = (date: string) => {
|
||||||
const filtered =
|
setExpandedDays((prev) => {
|
||||||
filter === "all"
|
const next = new Set(prev);
|
||||||
? recs
|
if (next.has(date)) next.delete(date);
|
||||||
: filter === "buy"
|
else next.add(date);
|
||||||
? recs.filter((r) => r.signal === "BUY")
|
return next;
|
||||||
: filter === "momentum"
|
});
|
||||||
? recs.filter((r) => r.strategy === "momentum")
|
};
|
||||||
: filter === "potential"
|
|
||||||
? recs.filter((r) => r.strategy === "potential")
|
const applyFilter = (recs: DayGroup["recommendations"]) => {
|
||||||
: recs.filter((r) => r.level === filter);
|
if (filter === "all") return recs;
|
||||||
|
if (filter === "buy") return recs.filter((r) => r.signal === "BUY");
|
||||||
|
if (filter === "breakout") return recs.filter((r) => r.entry_signal_type === "breakout");
|
||||||
|
if (filter === "pullback") return recs.filter((r) => r.entry_signal_type === "pullback");
|
||||||
|
if (filter === "launch") return recs.filter((r) => r.entry_signal_type === "launch");
|
||||||
|
return recs.filter((r) => r.level === filter);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 总数统计
|
||||||
|
const totalCount = dayGroups.reduce((sum, g) => sum + applyFilter(g.recommendations).length, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
||||||
@ -78,7 +111,8 @@ export default function RecommendationsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold tracking-tight">推荐列表</h1>
|
<h1 className="text-lg font-bold tracking-tight">推荐列表</h1>
|
||||||
<p className="text-xs text-text-muted mt-0.5">
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
共 <span className="font-mono tabular-nums">{filtered.length}</span> 只
|
共 <span className="font-mono tabular-nums">{totalCount}</span> 只 ·
|
||||||
|
<span className="font-mono tabular-nums ml-1">{dayGroups.length}</span> 天记录
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -109,12 +143,12 @@ export default function RecommendationsPage() {
|
|||||||
<div className="flex gap-2 mb-5 overflow-x-auto pb-1 animate-fade-in-up delay-75">
|
<div className="flex gap-2 mb-5 overflow-x-auto pb-1 animate-fade-in-up delay-75">
|
||||||
{[
|
{[
|
||||||
{ key: "all", label: "全部" },
|
{ key: "all", label: "全部" },
|
||||||
{ key: "momentum", label: "强中选强" },
|
{ key: "breakout", label: "突破型" },
|
||||||
{ key: "potential", label: "潜在启动" },
|
{ key: "pullback", label: "回踩型" },
|
||||||
|
{ key: "launch", label: "启动型" },
|
||||||
{ key: "buy", label: "买入信号" },
|
{ key: "buy", label: "买入信号" },
|
||||||
{ key: "强烈推荐", label: "强烈推荐" },
|
{ key: "强烈推荐", label: "强烈推荐" },
|
||||||
{ key: "推荐", label: "推荐" },
|
{ key: "推荐", label: "推荐" },
|
||||||
{ key: "观望", label: "观望" },
|
|
||||||
].map(({ key, label }) => (
|
].map(({ key, label }) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
@ -130,18 +164,96 @@ export default function RecommendationsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{/* Day groups */}
|
||||||
|
{dayGroups.length === 0 ? (
|
||||||
<div className="glass-card-static p-12 text-center animate-fade-in-up">
|
<div className="glass-card-static p-12 text-center animate-fade-in-up">
|
||||||
<div className="text-text-muted text-sm mb-1">暂无{filter === "all" ? "" : "符合条件的"}推荐</div>
|
<div className="text-text-muted text-sm mb-1">暂无推荐记录</div>
|
||||||
<div className="text-text-muted/50 text-xs">尝试切换筛选条件或触发新的扫描</div>
|
<div className="text-text-muted/50 text-xs">触发新的扫描以生成推荐</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="space-y-4">
|
||||||
{filtered.map((rec, i) => (
|
{dayGroups.map((group, gi) => {
|
||||||
<div key={rec.ts_code} className="animate-fade-in-up" style={{ animationDelay: `${i * 60}ms` }}>
|
const filtered = applyFilter(group.recommendations);
|
||||||
<StockCard rec={rec} showLLMLoading={llmEnabled} />
|
if (filter !== "all" && filtered.length === 0) return null;
|
||||||
</div>
|
|
||||||
))}
|
const isExpanded = expandedDays.has(group.date);
|
||||||
|
const isToday = gi === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.date} className="animate-fade-in-up" style={{ animationDelay: `${gi * 60}ms` }}>
|
||||||
|
{/* Date header */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleDay(group.date)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 glass-card-static hover:bg-white/[0.03] transition-colors group"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={`text-text-muted/60 transition-transform duration-200 shrink-0 ${isExpanded ? "rotate-90" : ""}`}
|
||||||
|
>
|
||||||
|
<path d="M9 18l6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5 flex-1 text-left">
|
||||||
|
<span className={`text-sm font-semibold ${isToday ? "text-amber-400" : "text-text-primary"}`}>
|
||||||
|
{formatDate(group.date)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-muted/60 font-mono tabular-nums">
|
||||||
|
{group.date}
|
||||||
|
</span>
|
||||||
|
{isToday && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-amber-500/15 text-amber-400 border border-amber-500/20 font-medium">
|
||||||
|
NEW
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-text-muted shrink-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-text-muted/50">均分</span>
|
||||||
|
<span className="font-mono tabular-nums font-semibold text-text-secondary">
|
||||||
|
{group.avg_score}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-text-muted/50">买入</span>
|
||||||
|
<span className="font-mono tabular-nums font-semibold text-red-400">
|
||||||
|
{group.buy_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-text-muted/50">共</span>
|
||||||
|
<span className="font-mono tabular-nums font-semibold text-text-secondary">
|
||||||
|
{filter === "all" ? group.count : filtered.length}
|
||||||
|
</span>
|
||||||
|
<span className="text-text-muted/50">只</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Cards */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-2 px-1">
|
||||||
|
{filtered.map((rec, i) => (
|
||||||
|
<div
|
||||||
|
key={`${group.date}-${rec.ts_code}`}
|
||||||
|
className="animate-fade-in-up"
|
||||||
|
style={{ animationDelay: `${i * 40}ms` }}
|
||||||
|
>
|
||||||
|
<StockCard rec={rec} showLLMLoading={isToday && llmEnabled} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,10 +2,186 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { fetchAPI } from "@/lib/api";
|
import { fetchAPI } from "@/lib/api";
|
||||||
import type { SectorData } from "@/lib/api";
|
import type { SectorData, LeadingStock } from "@/lib/api";
|
||||||
import SectorHeatmap from "@/components/sector-heatmap";
|
import { formatNumber } from "@/lib/utils";
|
||||||
import { useWebSocket } from "@/hooks/use-websocket";
|
import { useWebSocket } from "@/hooks/use-websocket";
|
||||||
|
|
||||||
|
function getStageInfo(stage: string) {
|
||||||
|
switch (stage) {
|
||||||
|
case "early":
|
||||||
|
return { label: "启动期", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/15" };
|
||||||
|
case "mid":
|
||||||
|
return { label: "发展期", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/15" };
|
||||||
|
case "late":
|
||||||
|
return { label: "后期", color: "text-orange-400", bg: "bg-orange-500/10 border-orange-500/15" };
|
||||||
|
case "end":
|
||||||
|
return { label: "尾声", color: "text-red-400", bg: "bg-red-500/10 border-red-500/15" };
|
||||||
|
default:
|
||||||
|
return { label: "—", color: "text-text-muted", bg: "bg-white/[0.03] border-white/[0.06]" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 迷你柱状图:近5日涨跌幅 */
|
||||||
|
function MiniBarChart({ data }: { data: number[] }) {
|
||||||
|
if (!data.length) return null;
|
||||||
|
const maxAbs = Math.max(...data.map(Math.abs), 0.1);
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-1 h-8">
|
||||||
|
{data.map((v, i) => {
|
||||||
|
const h = Math.max(Math.abs(v) / maxAbs * 100, 8);
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 flex flex-col justify-end items-center" style={{ height: "100%" }}>
|
||||||
|
<div
|
||||||
|
className={`w-full rounded-sm transition-all duration-300 ${v >= 0 ? "bg-red-500/50" : "bg-emerald-500/50"}`}
|
||||||
|
style={{ height: `${h}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 领涨股标签 */
|
||||||
|
function LeadingStockTag({ stock }: { stock: LeadingStock }) {
|
||||||
|
const isLimitUp = stock.pct_chg >= 9.8;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`/stock/${stock.ts_code}`}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-lg bg-white/[0.03] border border-white/[0.06] hover:bg-white/[0.06] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-text-secondary font-medium">{stock.name}</span>
|
||||||
|
<span className={`font-mono tabular-nums ${isLimitUp ? "text-red-400 font-bold" : stock.pct_chg > 0 ? "text-red-400/80" : "text-emerald-400/80"}`}>
|
||||||
|
{stock.pct_chg > 0 ? "+" : ""}{stock.pct_chg}%
|
||||||
|
</span>
|
||||||
|
{stock.limit_times != null && stock.limit_times > 1 && (
|
||||||
|
<span className="text-[9px] px-1 py-0.5 rounded bg-red-500/15 text-red-400 font-bold">
|
||||||
|
{stock.limit_times}连板
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectorDetailCard({ sector, index }: { sector: SectorData; index: number }) {
|
||||||
|
const displayPct = sector.realtime_pct_change ?? sector.pct_change;
|
||||||
|
const isUp = displayPct > 0;
|
||||||
|
const displayLimitUp = sector.realtime_limit_up_count ?? sector.limit_up_count;
|
||||||
|
const leaders = sector.is_realtime
|
||||||
|
? (sector.leading_stocks_realtime?.length ? sector.leading_stocks_realtime : sector.leading_stocks)
|
||||||
|
: sector.leading_stocks;
|
||||||
|
const stage = getStageInfo(sector.stage ?? "");
|
||||||
|
const isTop3 = index < 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="glass-card p-5 animate-fade-in-up"
|
||||||
|
style={{ animationDelay: `${index * 60}ms` }}
|
||||||
|
>
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className={`w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 ${
|
||||||
|
index === 0
|
||||||
|
? "bg-gradient-to-br from-amber-500/30 to-amber-600/20 text-amber-400 border border-amber-500/20"
|
||||||
|
: index === 1
|
||||||
|
? "bg-gradient-to-br from-slate-400/20 to-slate-500/15 text-slate-300 border border-slate-400/15"
|
||||||
|
: index === 2
|
||||||
|
? "bg-gradient-to-br from-amber-700/20 to-amber-800/15 text-amber-400 border border-amber-600/15"
|
||||||
|
: "bg-white/[0.03] text-text-muted border border-white/[0.04]"
|
||||||
|
}`}>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-sm font-semibold ${isTop3 ? "text-text-primary" : "text-text-secondary"}`}>
|
||||||
|
{sector.sector_name}
|
||||||
|
</span>
|
||||||
|
{sector.is_realtime && (
|
||||||
|
<span className="text-[9px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400/80 border border-emerald-500/15">
|
||||||
|
实时
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-text-muted/60 mt-0.5">
|
||||||
|
{sector.member_count ? `${sector.member_count}只成分股` : ""}
|
||||||
|
{sector.member_count && sector.turnover_avg ? " · " : ""}
|
||||||
|
{sector.turnover_avg ? `均换手 ${sector.turnover_avg}%` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className={`text-base font-bold font-mono tabular-nums ${isUp ? "text-red-400" : "text-emerald-400"}`}>
|
||||||
|
{displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-md border font-medium ${stage.bg} ${stage.color}`}>
|
||||||
|
{stage.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-muted/60">
|
||||||
|
连{sector.days_continuous}天
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics row */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-3">
|
||||||
|
<div className="bg-white/[0.02] rounded-lg px-3 py-2">
|
||||||
|
<div className="text-[10px] text-text-muted/50 mb-0.5">资金净流入</div>
|
||||||
|
<div className={`text-xs font-mono tabular-nums font-semibold ${sector.capital_inflow > 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||||
|
{sector.capital_inflow > 0 ? "+" : ""}{formatNumber(sector.capital_inflow)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/[0.02] rounded-lg px-3 py-2">
|
||||||
|
<div className="text-[10px] text-text-muted/50 mb-0.5">涨停股</div>
|
||||||
|
<div className="text-xs font-mono tabular-nums font-semibold text-text-secondary">
|
||||||
|
{displayLimitUp}<span className="text-text-muted/40"> 只</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/[0.02] rounded-lg px-3 py-2">
|
||||||
|
<div className="text-[10px] text-text-muted/50 mb-0.5">热度评分</div>
|
||||||
|
<div className={`text-xs font-mono tabular-nums font-semibold ${
|
||||||
|
sector.heat_score >= 70 ? "text-amber-400" : sector.heat_score >= 50 ? "text-text-secondary" : "text-text-muted"
|
||||||
|
}`}>
|
||||||
|
{sector.heat_score.toFixed(0)}
|
||||||
|
<span className="text-text-muted/40 text-[10px]">/100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5日趋势图 */}
|
||||||
|
{sector.pct_trend && sector.pct_trend.length > 1 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-[10px] text-text-muted/50">近5日走势</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{sector.pct_trend.map((v, i) => (
|
||||||
|
<span key={i} className={`text-[9px] font-mono tabular-nums ${v >= 0 ? "text-red-400/60" : "text-emerald-400/60"}`}>
|
||||||
|
{v > 0 ? "+" : ""}{v.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MiniBarChart data={sector.pct_trend} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 领涨股 */}
|
||||||
|
{leaders && leaders.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] text-text-muted/50 mb-1.5 block">领涨股</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{leaders.map((s) => (
|
||||||
|
<LeadingStockTag key={s.ts_code} stock={s} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SectorsPage() {
|
export default function SectorsPage() {
|
||||||
const [sectors, setSectors] = useState<SectorData[]>([]);
|
const [sectors, setSectors] = useState<SectorData[]>([]);
|
||||||
|
|
||||||
@ -22,20 +198,38 @@ export default function SectorsPage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
// WebSocket 触发刷新(扫描完成时更新)
|
|
||||||
useWebSocket(
|
useWebSocket(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, [loadData])
|
}, [loadData])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasRealtime = sectors.some((s) => s.is_realtime);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
||||||
<div className="mb-5 animate-fade-in-up">
|
<div className="flex items-center justify-between mb-5 animate-fade-in-up">
|
||||||
<h1 className="text-lg font-bold tracking-tight">板块分析</h1>
|
<div>
|
||||||
<p className="text-xs text-text-muted mt-0.5">基于资金流向和涨跌表现的热度排名</p>
|
<h1 className="text-lg font-bold tracking-tight">板块分析</h1>
|
||||||
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
|
基于资金流向、涨跌表现、龙头股的热度排名
|
||||||
|
{hasRealtime && <span className="text-emerald-400/60 ml-1">· 实时数据</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SectorHeatmap sectors={sectors} />
|
|
||||||
|
{!sectors.length ? (
|
||||||
|
<div className="glass-card-static p-12 text-center animate-fade-in-up">
|
||||||
|
<div className="text-text-muted text-sm mb-1">暂无板块数据</div>
|
||||||
|
<div className="text-text-muted/50 text-xs">触发扫描后自动更新</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{sectors.map((sector, i) => (
|
||||||
|
<SectorDetailCard key={sector.sector_code} sector={sector} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { getLevelBadge, getSignalColor, getScoreColor } from "@/lib/utils";
|
import { getLevelBadge, getSignalColor, getScoreColor } from "@/lib/utils";
|
||||||
import type { RecommendationData } from "@/lib/api";
|
import type { RecommendationData } from "@/lib/api";
|
||||||
|
|
||||||
export default function StockCard({ rec, showLLMLoading = false }: { rec: RecommendationData; showLLMLoading?: boolean }) {
|
export default function StockCard({ rec, showLLMLoading = false }: { rec: RecommendationData; showLLMLoading?: boolean }) {
|
||||||
const badge = getLevelBadge(rec.level);
|
const badge = getLevelBadge(rec.level);
|
||||||
|
const [aiExpanded, setAiExpanded] = useState(false);
|
||||||
|
|
||||||
// 策略标签
|
// 入场信号标签
|
||||||
const strategyLabel = rec.strategy === "potential" ? "潜在启动" : "强中选强";
|
const signalTypeMap: Record<string, { label: string; style: string }> = {
|
||||||
const strategyStyle = rec.strategy === "potential"
|
breakout: { label: "突破型", style: "bg-red-500/15 text-red-400 border-red-500/20" },
|
||||||
? "bg-cyan-500/15 text-cyan-400 border-cyan-500/20"
|
pullback: { label: "回踩型", style: "bg-blue-500/15 text-blue-400 border-blue-500/20" },
|
||||||
: "bg-amber-500/15 text-amber-400 border-amber-500/20";
|
launch: { label: "启动型", style: "bg-orange-500/15 text-orange-400 border-orange-500/20" },
|
||||||
|
};
|
||||||
|
// 向后兼容:旧数据使用 strategy 字段
|
||||||
|
const signalInfo = signalTypeMap[rec.entry_signal_type || ""];
|
||||||
|
const legacyStrategy = rec.strategy === "potential"
|
||||||
|
? { label: "潜在启动", style: "bg-cyan-500/15 text-cyan-400 border-cyan-500/20" }
|
||||||
|
: rec.strategy === "momentum"
|
||||||
|
? { label: "强中选强", style: "bg-amber-500/15 text-amber-400 border-amber-500/20" }
|
||||||
|
: null;
|
||||||
|
const tag = signalInfo || legacyStrategy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@ -25,9 +36,11 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
|
|||||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${badge.bg} ${badge.text}`}>
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${badge.bg} ${badge.text}`}>
|
||||||
{rec.level}
|
{rec.level}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium border ${strategyStyle}`}>
|
{tag && (
|
||||||
{strategyLabel}
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium border ${tag.style}`}>
|
||||||
</span>
|
{tag.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text-muted mt-1 font-mono tabular-nums">
|
<div className="text-xs text-text-muted mt-1 font-mono tabular-nums">
|
||||||
{rec.ts_code} <span className="text-text-muted/40 mx-1">·</span> {rec.sector}
|
{rec.ts_code} <span className="text-text-muted/40 mx-1">·</span> {rec.sector}
|
||||||
@ -79,24 +92,34 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Analysis */}
|
{/* AI Analysis — collapsible */}
|
||||||
{rec.llm_analysis && rec.llm_analysis !== "AI 分析暂时不可用" ? (
|
{rec.llm_analysis && rec.llm_analysis !== "AI 分析暂时不可用" ? (
|
||||||
<div className="mt-3 bg-accent-cyan/[0.06] border border-accent-cyan/[0.12] rounded-xl px-4 py-3">
|
<div className="mt-3">
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<button
|
||||||
<div className="text-xs text-accent-cyan/80 font-semibold tracking-wider">AI 分析</div>
|
onClick={(e) => { e.preventDefault(); setAiExpanded(!aiExpanded); }}
|
||||||
|
className="w-full flex items-center justify-between bg-accent-cyan/[0.06] border border-accent-cyan/[0.12] rounded-xl px-4 py-2.5 hover:bg-accent-cyan/[0.09] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-accent-cyan/80 font-semibold tracking-wider">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${aiExpanded ? "rotate-90" : ""}`}>
|
||||||
|
<path d="M9 18l6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
AI 分析
|
||||||
|
</div>
|
||||||
{rec.llm_score != null && (
|
{rec.llm_score != null && (
|
||||||
<div className="text-xs font-mono tabular-nums">
|
<div className="text-xs font-mono tabular-nums">
|
||||||
<span className="text-text-muted">AI评分 </span>
|
<span className="text-text-muted">评分 </span>
|
||||||
<span className={`font-bold ${rec.llm_score >= 7 ? "text-amber-400" : rec.llm_score >= 5 ? "text-cyan-400" : "text-text-muted"}`}>
|
<span className={`font-bold ${rec.llm_score >= 7 ? "text-amber-400" : rec.llm_score >= 5 ? "text-cyan-400" : "text-text-muted"}`}>
|
||||||
{rec.llm_score}
|
{rec.llm_score}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-text-muted/50">/10</span>
|
<span className="text-text-muted/50">/10</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
<div className="text-xs text-text-secondary leading-relaxed whitespace-pre-line">
|
{aiExpanded && (
|
||||||
<MarkdownText text={rec.llm_analysis} />
|
<div className="text-xs text-text-secondary leading-relaxed whitespace-pre-line bg-accent-cyan/[0.03] border border-t-0 border-accent-cyan/[0.08] rounded-b-xl px-4 py-3">
|
||||||
</div>
|
<MarkdownText text={rec.llm_analysis} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : rec.llm_analysis === "AI 分析暂时不可用" ? (
|
) : rec.llm_analysis === "AI 分析暂时不可用" ? (
|
||||||
<div className="mt-3 text-xs text-text-muted/50 flex items-center gap-1.5">
|
<div className="mt-3 text-xs text-text-muted/50 flex items-center gap-1.5">
|
||||||
|
|||||||
@ -109,13 +109,22 @@ export interface RecommendationData {
|
|||||||
stop_loss: number | null;
|
stop_loss: number | null;
|
||||||
reasons: string[];
|
reasons: string[];
|
||||||
risk_note: string;
|
risk_note: string;
|
||||||
strategy?: "momentum" | "potential";
|
strategy?: "momentum" | "potential" | "trend_breakout";
|
||||||
|
entry_signal_type?: "breakout" | "pullback" | "launch" | "none";
|
||||||
llm_analysis?: string;
|
llm_analysis?: string;
|
||||||
llm_score?: number | null;
|
llm_score?: number | null;
|
||||||
scan_session: string;
|
scan_session: string;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LeadingStock {
|
||||||
|
ts_code: string;
|
||||||
|
name: string;
|
||||||
|
pct_chg: number;
|
||||||
|
amount: number;
|
||||||
|
limit_times?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SectorData {
|
export interface SectorData {
|
||||||
sector_code: string;
|
sector_code: string;
|
||||||
sector_name: string;
|
sector_name: string;
|
||||||
@ -124,6 +133,13 @@ export interface SectorData {
|
|||||||
limit_up_count: number;
|
limit_up_count: number;
|
||||||
days_continuous: number;
|
days_continuous: number;
|
||||||
heat_score: number;
|
heat_score: number;
|
||||||
|
stage?: string;
|
||||||
|
member_count?: number;
|
||||||
|
leading_stocks?: LeadingStock[];
|
||||||
|
leading_stocks_realtime?: LeadingStock[] | null;
|
||||||
|
pct_trend?: number[];
|
||||||
|
turnover_avg?: number;
|
||||||
|
main_force_ratio?: number;
|
||||||
realtime_pct_change?: number | null;
|
realtime_pct_change?: number | null;
|
||||||
realtime_limit_up_count?: number | null;
|
realtime_limit_up_count?: number | null;
|
||||||
is_realtime?: boolean;
|
is_realtime?: boolean;
|
||||||
@ -134,6 +150,14 @@ export interface LatestResult {
|
|||||||
recommendations: RecommendationData[];
|
recommendations: RecommendationData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DayGroup {
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
buy_count: number;
|
||||||
|
avg_score: number;
|
||||||
|
recommendations: RecommendationData[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user