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
|
||||
|
||||
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 板块做逐个详细查询(ths_daily/ths_member),
|
||||
避免遍历全部数百个板块导致大量 API 调用。
|
||||
|
||||
增强分析:
|
||||
- 领涨股:每个板块中涨幅前3的成分股
|
||||
- 资金趋势:近5日板块资金净流入走势
|
||||
- 涨跌趋势:近5日板块涨跌幅走势
|
||||
- 主力资金占比
|
||||
"""
|
||||
|
||||
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]:
|
||||
"""扫描热门板块,返回按热度排名的板块列表"""
|
||||
"""扫描热门板块,返回按热度排名的板块列表(含深度分析)"""
|
||||
if not 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)} 进入详细分析")
|
||||
|
||||
# 构建资金流向查找表
|
||||
# Tushare moneyflow_ind_ths 的金额单位是亿元,统一转换为万元
|
||||
_UNIT_CONV = 10000
|
||||
mf_lookup = {}
|
||||
# 同时构建主力买卖数据(用于计算主力占比)
|
||||
mf_detail = {}
|
||||
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 = {}
|
||||
if "industry" in sector_mf.columns:
|
||||
for _, r in sector_mf.iterrows():
|
||||
if pd.notna(r.get("industry")):
|
||||
name_lookup[r["ts_code"]] = str(r["industry"])
|
||||
# 补充:从 ths_index type=I(行业板块)获取名称
|
||||
index_list = tushare_client.get_ths_index_list("I")
|
||||
if not index_list.empty:
|
||||
for _, r in index_list.iterrows():
|
||||
@ -75,22 +90,34 @@ def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
|
||||
# ── 第二步:获取涨跌停列表(1 次 API 调用)──
|
||||
limit_df = tushare_client.get_limit_list(trade_date)
|
||||
limit_up_codes = set()
|
||||
# 同时收集涨停股的涨跌幅信息(用于领涨股展示)
|
||||
limit_up_info: dict[str, dict] = {}
|
||||
if not limit_df.empty:
|
||||
up_df = limit_df[limit_df["limit"] == "U"]
|
||||
up_df = up_df[~up_df["name"].str.contains("ST", na=False)]
|
||||
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 = []
|
||||
|
||||
for ts_code in top_codes:
|
||||
# 板块名称从 ths_index 查找表获取
|
||||
sector_name = name_lookup.get(ts_code, ts_code)
|
||||
|
||||
# 板块日线 - 获取近5日数据(1 次 API)
|
||||
# 板块日线 - 获取近5日数据
|
||||
ths_daily = tushare_client.get_ths_daily(ts_code, days=5)
|
||||
pct_change = 0.0
|
||||
days_continuous = 0
|
||||
pct_trend: list[float] = []
|
||||
|
||||
if not ths_daily.empty:
|
||||
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)
|
||||
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():
|
||||
if d["pct_change"] > 0:
|
||||
@ -108,15 +138,75 @@ def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
|
||||
else:
|
||||
break
|
||||
|
||||
# 板块资金净流入(从预筛数据中直接取)
|
||||
# 板块资金净流入
|
||||
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
|
||||
leading_stocks: list[dict] = []
|
||||
member_count = 0
|
||||
turnover_avg = 0.0
|
||||
|
||||
members = tushare_client.get_ths_members(ts_code)
|
||||
if not members.empty and "con_code" in members.columns:
|
||||
member_codes = set(members["con_code"].tolist())
|
||||
limit_up_count = len(limit_up_codes & member_codes)
|
||||
member_codes = list(members["con_code"].tolist())
|
||||
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(
|
||||
sector_code=ts_code,
|
||||
@ -125,6 +215,11 @@ def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
|
||||
capital_inflow=round(capital_inflow, 2),
|
||||
limit_up_count=limit_up_count,
|
||||
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:
|
||||
@ -133,13 +228,13 @@ def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
|
||||
# ── 板块阶段判定 ──
|
||||
for s in sectors:
|
||||
if s.days_continuous <= 2:
|
||||
s.stage = "early" # 启动期,安全
|
||||
s.stage = "early"
|
||||
elif s.days_continuous == 3:
|
||||
s.stage = "mid" # 发展期,正常
|
||||
s.stage = "mid"
|
||||
elif s.days_continuous == 4:
|
||||
s.stage = "late" # 后期,谨慎
|
||||
s.stage = "late"
|
||||
else:
|
||||
s.stage = "end" # 尾声,高风险
|
||||
s.stage = "end"
|
||||
|
||||
# ── 综合评分 ──
|
||||
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 +
|
||||
con_scores[i] * 0.20
|
||||
)
|
||||
# 连续2日以上资金流入的板块加分
|
||||
if s.days_continuous >= 2:
|
||||
heat += 5
|
||||
# 首日大涨(昨日涨幅<=0,今日>3%)可视为新热点,加分
|
||||
if s.days_continuous == 1 and s.pct_change > 3:
|
||||
heat += 3
|
||||
|
||||
# 板块阶段调整:早期加分,尾声减分(防追高)
|
||||
if s.stage == "early":
|
||||
heat += 8 # 启动期,介入安全,大幅加分
|
||||
heat += 8
|
||||
elif s.stage == "late":
|
||||
heat -= 5 # 后期,风险上升
|
||||
heat -= 5
|
||||
elif s.stage == "end":
|
||||
heat -= 12 # 尾声,大幅减分
|
||||
heat -= 12
|
||||
|
||||
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]
|
||||
for s in top:
|
||||
logger.info(f"热门板块: {s.sector_name} 涨幅{s.pct_change}% 资金{s.capital_inflow}万 "
|
||||
f"涨停{s.limit_up_count} 连续{s.days_continuous}天 阶段={s.stage} 热度{s.heat_score}")
|
||||
leaders = ", ".join(f'{l["name"]}({l["pct_chg"]}%)' for l in s.leading_stocks[:3])
|
||||
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
|
||||
|
||||
@ -83,6 +83,7 @@ def add_all_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
||||
# 量均线
|
||||
df["vol_ma5"] = calc_volume_ma(vol, 5)
|
||||
df["vol_ma10"] = calc_volume_ma(vol, 10)
|
||||
df["vol_ma20"] = calc_volume_ma(vol, 20)
|
||||
|
||||
# 涨跌幅(如果没有 pct_chg 列)
|
||||
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_score": r.llm_score,
|
||||
"strategy": r.strategy,
|
||||
"entry_signal_type": r.entry_signal_type,
|
||||
"scan_session": r.scan_session,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
@ -88,6 +89,5 @@ async def get_scan_status():
|
||||
|
||||
@router.get("/history")
|
||||
async def get_history(days: int = 7):
|
||||
"""获取历史推荐"""
|
||||
rows = await get_recommendation_history(days)
|
||||
return rows
|
||||
"""获取历史推荐(按日期分组)"""
|
||||
return await get_recommendation_history(days)
|
||||
|
||||
@ -66,13 +66,26 @@ async def _enrich_sectors_realtime(sectors_data: list[dict]) -> list[dict]:
|
||||
1 for q in member_quotes
|
||||
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:
|
||||
s["realtime_pct_change"] = None
|
||||
s["realtime_limit_up_count"] = None
|
||||
s["leading_stocks_realtime"] = None
|
||||
|
||||
s["is_realtime"] = True
|
||||
|
||||
# 盘中按实时涨幅重新排序(涨幅高的排前面)
|
||||
# 盘中按实时涨幅重新排序
|
||||
sectors_data.sort(key=lambda s: s.get("realtime_pct_change") or 0, reverse=True)
|
||||
|
||||
return sectors_data
|
||||
@ -92,6 +105,12 @@ async def get_hot_sectors(limit: int = 10):
|
||||
"days_continuous": s.days_continuous,
|
||||
"heat_score": s.heat_score,
|
||||
"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]
|
||||
]
|
||||
|
||||
@ -43,6 +43,11 @@ class Settings(BaseSettings):
|
||||
# 风控
|
||||
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)
|
||||
deepseek_api_key: str = ""
|
||||
deepseek_base_url: str = "https://api.deepseek.com/v1"
|
||||
|
||||
Binary file not shown.
@ -57,12 +57,20 @@ class SectorInfo(BaseModel):
|
||||
sector_code: str
|
||||
sector_name: str
|
||||
pct_change: float = 0 # 涨跌幅 %
|
||||
capital_inflow: float = 0 # 主力净流入(万)
|
||||
capital_inflow: float = 0 # 主力净流入(万元,原始数据来自Tushare亿元×10000)
|
||||
limit_up_count: int = 0 # 涨停数
|
||||
days_continuous: int = 0 # 连续资金流入天数
|
||||
heat_score: float = 0 # 热度综合评分
|
||||
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):
|
||||
trade_date: str
|
||||
@ -119,7 +127,8 @@ class Recommendation(BaseModel):
|
||||
reasons: list[str] = []
|
||||
risk_note: 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_score: float | None = None # AI 评分 1-10
|
||||
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 market_temperature ADD COLUMN max_streak INTEGER",
|
||||
"ALTER TABLE market_temperature ADD COLUMN broken_rate REAL",
|
||||
"ALTER TABLE recommendations ADD COLUMN entry_signal_type TEXT DEFAULT 'none'",
|
||||
]:
|
||||
try:
|
||||
await conn.execute(
|
||||
|
||||
@ -26,7 +26,8 @@ recommendations_table = Table(
|
||||
Column("stop_loss", Float),
|
||||
Column("reasons", Text),
|
||||
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("scan_session", Text),
|
||||
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]:
|
||||
"""获取历史推荐记录"""
|
||||
"""获取历史推荐记录,按日期分组返回"""
|
||||
from datetime import timedelta
|
||||
import json
|
||||
|
||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
async with get_db() as db:
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import text
|
||||
|
||||
# 查询所有历史推荐,按 ts_code 去重(每天取最新一条)
|
||||
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})
|
||||
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):
|
||||
@ -118,7 +199,12 @@ async def _save_to_db(result: dict):
|
||||
)
|
||||
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
|
||||
for rec in result.get("recommendations", []):
|
||||
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),
|
||||
llm_analysis=rec.llm_analysis,
|
||||
strategy=rec.strategy,
|
||||
entry_signal_type=rec.entry_signal_type,
|
||||
llm_score=rec.llm_score,
|
||||
scan_session=rec.scan_session,
|
||||
)
|
||||
@ -177,9 +264,11 @@ async def _load_today_from_db() -> dict:
|
||||
temperature=m["temperature"],
|
||||
)
|
||||
|
||||
# 加载推荐
|
||||
# 加载推荐(按 ts_code 去重,取最新一条)
|
||||
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}
|
||||
)
|
||||
rows = result.fetchall()
|
||||
@ -203,7 +292,8 @@ async def _load_today_from_db() -> dict:
|
||||
stop_loss=r["stop_loss"],
|
||||
reasons=json.loads(r["reasons"]) if r["reasons"] else [],
|
||||
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"),
|
||||
scan_session=r["scan_session"] or "",
|
||||
))
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""双通道漏斗筛选器
|
||||
"""趋势突破统一筛选器
|
||||
|
||||
Channel A(强中选强):市场温度 → 板块热度 → 资金筛选 → 技术信号
|
||||
Channel B(潜在启动):全市场技术扫描 → 底部形态 → 估值筛选
|
||||
三阶段管道:全市场批量预筛 → 资金流过滤 → 逐股深度分析
|
||||
评分公式:趋势&时机30% + 资金流25% + 供需20% + 板块共振15% + 市场温度10%
|
||||
|
||||
自动检测是否在交易时段:
|
||||
- 盘中模式:用前一日 Tushare 数据 + 腾讯实时行情混合筛选
|
||||
@ -12,22 +12,17 @@ import logging
|
||||
|
||||
from app.analysis.market_temp import calculate_market_temperature
|
||||
from app.analysis.sector_scanner import scan_hot_sectors
|
||||
from app.analysis.capital_flow import filter_stocks_by_capital
|
||||
from app.analysis.potential_scanner import scan_potential_breakout
|
||||
from app.analysis.trend_scanner import scan_trend_breakout
|
||||
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.config import settings, is_trading_hours
|
||||
from app.config import settings, is_trading_hours, is_market_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_screening(trade_date: str = None) -> dict:
|
||||
"""执行双通道筛选流程
|
||||
|
||||
自动检测交易时段:
|
||||
- 盘中 → 用前一日板块+实时行情筛选
|
||||
- 盘后 → 用当日完整数据筛选
|
||||
"""执行趋势突破筛选流程
|
||||
|
||||
返回: {
|
||||
"market_temp": MarketTemperature,
|
||||
@ -36,11 +31,11 @@ async def run_screening(trade_date: str = None) -> dict:
|
||||
"scan_mode": "intraday" | "post_market",
|
||||
}
|
||||
"""
|
||||
intraday = is_trading_hours()
|
||||
intraday = is_market_session()
|
||||
scan_mode = "intraday" if intraday else "post_market"
|
||||
logger.info(f"=== 筛选模式: {'盘中实时' if intraday else '盘后'} ===")
|
||||
|
||||
# ── 市场温度(共享) ──
|
||||
# ── 市场温度 ──
|
||||
logger.info("=== 市场温度计 ===")
|
||||
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
|
||||
|
||||
# ── 板块热度(Channel A 需要) ──
|
||||
# ── 板块热度(用于板块共振评分) ──
|
||||
logger.info("=== 板块热度扫描 ===")
|
||||
all_sectors = scan_hot_sectors(trade_date)
|
||||
hot_sectors = all_sectors[:settings.top_sector_count]
|
||||
|
||||
# ── Channel A:强中选强 ──
|
||||
recommendations_a = []
|
||||
capital_filtered = []
|
||||
# 盘中用实时行情更新板块涨幅和涨停数
|
||||
if intraday:
|
||||
hot_sectors = await intraday_sector_scan(hot_sectors)
|
||||
|
||||
if hot_sectors:
|
||||
if intraday:
|
||||
logger.info("=== Channel A:盘中实时个股筛选 ===")
|
||||
capital_filtered = await intraday_filter_stocks(hot_sectors)
|
||||
else:
|
||||
logger.info("=== Channel A:个股资金筛选 ===")
|
||||
capital_filtered = await filter_stocks_by_capital(hot_sectors, trade_date)
|
||||
# ── 趋势突破三阶段管道 ──
|
||||
logger.info("=== 趋势突破扫描 ===")
|
||||
candidates = await scan_trend_breakout(
|
||||
trade_date=trade_date,
|
||||
market_temp=market_temp,
|
||||
hot_sectors=hot_sectors,
|
||||
intraday=intraday,
|
||||
)
|
||||
|
||||
if capital_filtered:
|
||||
recommendations_a = _build_recommendations(
|
||||
capital_filtered, market_temp, hot_sectors,
|
||||
market_temp_score=market_temp_score,
|
||||
strategy="momentum", intraday=intraday,
|
||||
)
|
||||
if not candidates:
|
||||
logger.info("=== 筛选完成: 0 只股票 ===")
|
||||
return {
|
||||
"market_temp": market_temp,
|
||||
"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:潜在启动扫描 ===")
|
||||
exclude_codes = {r.ts_code for r in recommendations_a}
|
||||
potential_filtered = scan_potential_breakout(trade_date, exclude_codes)
|
||||
# 过滤低质量推荐
|
||||
recommendations = [r for r in recommendations if r.score >= 40]
|
||||
|
||||
recommendations_b = []
|
||||
if potential_filtered:
|
||||
recommendations_b = _build_recommendations(
|
||||
potential_filtered, market_temp, hot_sectors,
|
||||
market_temp_score=market_temp_score,
|
||||
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}")
|
||||
logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===")
|
||||
for r in recommendations[:5]:
|
||||
signal_map = {"breakout": "突破型", "pullback": "回踩型", "launch": "启动型"}
|
||||
signal_label = signal_map.get(r.entry_signal_type, r.entry_signal_type)
|
||||
logger.info(f" [{signal_label}] {r.name}({r.ts_code}) {r.level} 评分={r.score} 信号={r.signal}")
|
||||
|
||||
return {
|
||||
"market_temp": market_temp,
|
||||
"hot_sectors": hot_sectors,
|
||||
"capital_filtered": capital_filtered,
|
||||
"recommendations": all_recommendations,
|
||||
"recommendations": recommendations,
|
||||
"scan_mode": scan_mode,
|
||||
}
|
||||
|
||||
|
||||
def _build_recommendations(
|
||||
stocks: list[dict],
|
||||
def _build_trend_recommendations(
|
||||
candidates: list[dict],
|
||||
market_temp: MarketTemperature,
|
||||
hot_sectors: list[SectorInfo],
|
||||
market_temp_score: float = 0,
|
||||
strategy: str = "momentum",
|
||||
intraday: bool = False,
|
||||
) -> list[Recommendation]:
|
||||
"""从筛选结果构建推荐列表(Channel A/B 共用)"""
|
||||
"""从趋势突破扫描结果构建推荐列表
|
||||
|
||||
评分公式:趋势&时机30% + 资金流25% + 供需20% + 板块共振15% + 市场温度10%
|
||||
"""
|
||||
recommendations = []
|
||||
|
||||
for stock in stocks:
|
||||
for stock in candidates:
|
||||
ts_code = stock["ts_code"]
|
||||
name = stock["name"]
|
||||
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)
|
||||
|
||||
# 板块得分
|
||||
if strategy == "momentum":
|
||||
sector_score = _get_sector_score(sector, hot_sectors)
|
||||
sector_stage = _get_sector_stage(sector, hot_sectors)
|
||||
else:
|
||||
# Channel B:不在热门板块中,给基础分
|
||||
sector_score = 30.0
|
||||
sector_stage = "mid"
|
||||
|
||||
# 估值安全得分
|
||||
# 各维度得分
|
||||
trend_timing_score = stock.get("trend_timing_score", 50)
|
||||
supply_demand_score = stock.get("supply_demand_score", 50)
|
||||
capital_score = stock.get("capital_score", 50)
|
||||
position_score = stock.get("position_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":
|
||||
# 强中选强:板块和资金权重高
|
||||
# 市场10% + 板块20% + 资金20% + 技术15% + 位置安全15% + 估值安全20%
|
||||
final_score = (
|
||||
market_temp_score * 0.10 +
|
||||
sector_score * 0.20 +
|
||||
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
|
||||
)
|
||||
# 综合评分(新权重)
|
||||
final_score = (
|
||||
trend_timing_score * 0.30 +
|
||||
capital_score * 0.25 +
|
||||
supply_demand_score * 0.20 +
|
||||
sector_score * 0.15 +
|
||||
market_temp_score * 0.10
|
||||
)
|
||||
|
||||
# 板块尾声阶段额外惩罚(仅 Channel A)
|
||||
if strategy == "momentum":
|
||||
if sector_stage == "end":
|
||||
final_score *= 0.85
|
||||
elif sector_stage == "late":
|
||||
final_score *= 0.92
|
||||
# 风险乘数
|
||||
if tech_signal:
|
||||
if tech_signal.rally_pct_5d > 20:
|
||||
final_score *= 0.65
|
||||
elif tech_signal.rally_pct_5d > 15:
|
||||
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)
|
||||
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(
|
||||
ts_code=ts_code,
|
||||
@ -205,24 +196,41 @@ def _build_recommendations(
|
||||
score=round(final_score, 1),
|
||||
market_temp_score=round(market_temp_score, 1),
|
||||
sector_score=round(sector_score, 1),
|
||||
capital_score=round(stock["capital_score"], 1),
|
||||
technical_score=round(tech_signal.score, 1),
|
||||
capital_score=round(capital_score, 1),
|
||||
technical_score=round(stock.get("technical_score", 50), 1),
|
||||
position_score=round(position_score, 1),
|
||||
valuation_score=round(valuation_score, 1),
|
||||
signal=signal,
|
||||
entry_price=tech_signal.support_price,
|
||||
target_price=tech_signal.resist_price,
|
||||
stop_loss=tech_signal.stop_loss_price,
|
||||
entry_price=entry_price,
|
||||
target_price=target_price,
|
||||
stop_loss=stop_loss,
|
||||
reasons=reasons,
|
||||
risk_note=risk_note,
|
||||
level=level,
|
||||
strategy=strategy,
|
||||
strategy="trend_breakout",
|
||||
entry_signal_type=entry_signal_type,
|
||||
)
|
||||
recommendations.append(rec)
|
||||
|
||||
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:
|
||||
"""获取板块在热门板块中的得分"""
|
||||
for s in hot_sectors:
|
||||
@ -251,99 +259,93 @@ def _score_to_level(score: float) -> str:
|
||||
|
||||
|
||||
def _generate_reasons(
|
||||
stock: dict, tech: TechnicalSignal, market: MarketTemperature,
|
||||
intraday: bool = False, strategy: str = "momentum",
|
||||
stock: dict, tech: TechnicalSignal | None,
|
||||
market: MarketTemperature, intraday: bool = False,
|
||||
) -> list[str]:
|
||||
"""生成推荐理由"""
|
||||
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 tech.position_score >= 70:
|
||||
reasons.append("位置处于低位,距高点回调充分")
|
||||
if tech.macd_golden:
|
||||
reasons.append("MACD底部金叉,反转信号初现")
|
||||
if tech.pullback_support:
|
||||
reasons.append("缩量回踩支撑位,蓄势充分")
|
||||
if tech.big_yang:
|
||||
reasons.append("底部出现放量长阳,资金介入")
|
||||
if stock.get("valuation_score", 0) >= 60:
|
||||
reasons.append("估值安全,下行空间有限")
|
||||
if not reasons:
|
||||
reasons.append("技术面底部信号显现,关注启动时机")
|
||||
elif intraday:
|
||||
# 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}%,温和上攻")
|
||||
# 入场信号
|
||||
if entry_label:
|
||||
details = stock.get("entry_signal_details", {})
|
||||
if entry_type == "breakout":
|
||||
breakout_pct = details.get("breakout_pct", 0)
|
||||
vol_ratio = details.get("volume_ratio", 0)
|
||||
reasons.append(f"放量突破20日阻力位(涨幅{breakout_pct:.1f}%,量比{vol_ratio:.1f}倍)")
|
||||
elif entry_type == "pullback":
|
||||
support = details.get("support_ma", "")
|
||||
shrink = details.get("volume_shrink_ratio", 0)
|
||||
reasons.append(f"缩量回踩{support}支撑(量能收缩至{shrink:.0%})")
|
||||
elif entry_type == "launch":
|
||||
range_pct = details.get("price_range_pct", 0)
|
||||
shrink = details.get("volume_shrink_ratio", 0)
|
||||
reasons.append(f"高位缩量整理{range_pct:.1f}%后即将变盘(量缩至{shrink:.0%})")
|
||||
|
||||
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 = []
|
||||
if tech.ma_bullish:
|
||||
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:
|
||||
tech_reasons.append("MACD金叉")
|
||||
if tech.pullback_support:
|
||||
tech_reasons.append("缩量回踩支撑")
|
||||
if tech.big_yang:
|
||||
tech_reasons.append("底部放量长阳")
|
||||
if tech_reasons:
|
||||
reasons.append("技术面: " + "、".join(tech_reasons))
|
||||
|
||||
# 位置安全
|
||||
if tech.position_score >= 70:
|
||||
reasons.append("位置安全,距高点有空间")
|
||||
elif tech.position_score < 30:
|
||||
reasons.append("注意:短期涨幅较大,追高风险")
|
||||
|
||||
return reasons[:3]
|
||||
|
||||
|
||||
def _generate_risk_note(
|
||||
market: MarketTemperature, tech: TechnicalSignal,
|
||||
intraday: bool = False, strategy: str = "momentum",
|
||||
market: MarketTemperature,
|
||||
tech: TechnicalSignal | None,
|
||||
stock: dict,
|
||||
) -> str:
|
||||
"""生成风险提示"""
|
||||
notes = []
|
||||
if intraday:
|
||||
notes.append("盘中数据参考,需结合尾盘确认")
|
||||
if strategy == "potential":
|
||||
notes.append("底部股票可能继续盘整,注意时间成本")
|
||||
entry_type = stock.get("entry_signal_type", "")
|
||||
|
||||
if entry_type == "breakout":
|
||||
notes.append("突破型需警惕假突破,关注量能是否持续")
|
||||
elif entry_type == "pullback":
|
||||
notes.append("回踩型可能继续下探支撑,注意止损纪律")
|
||||
elif entry_type == "launch":
|
||||
notes.append("启动型整理可能延长,注意时间成本")
|
||||
|
||||
if market.temperature < 30:
|
||||
notes.append("市场情绪偏冷,系统性风险较高")
|
||||
elif market.temperature < 50:
|
||||
notes.append("市场情绪一般,注意仓位控制")
|
||||
if tech.score < 40:
|
||||
notes.append("技术面支撑不足")
|
||||
if tech.position_score < 30:
|
||||
notes.append(f"近期涨幅较大(5日{tech.rally_pct_5d}%),追高风险")
|
||||
if tech.rally_pct_10d > 20:
|
||||
notes.append(f"10日累涨{tech.rally_pct_10d}%,警惕回调")
|
||||
|
||||
if tech:
|
||||
if tech.position_score < 30:
|
||||
notes.append(f"近期涨幅较大(5日{tech.rally_pct_5d}%),追高风险")
|
||||
if tech.rally_pct_10d > 20:
|
||||
notes.append(f"10日累涨{tech.rally_pct_10d}%,警惕回调")
|
||||
|
||||
if not notes:
|
||||
return "注意设好止损,控制仓位"
|
||||
return ";".join(notes)
|
||||
|
||||
Binary file not shown.
@ -10,10 +10,7 @@ import logging
|
||||
import re
|
||||
|
||||
from app.llm.client import chat_completion
|
||||
from app.llm.prompts import (
|
||||
MOMENTUM_ANALYSIS_PROMPT,
|
||||
POTENTIAL_ANALYSIS_PROMPT,
|
||||
)
|
||||
from app.llm.prompts import TREND_BREAKOUT_ANALYSIS_PROMPT
|
||||
from app.config import settings
|
||||
|
||||
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)
|
||||
|
||||
strategy_label = "强中选强" if rec.strategy == "momentum" else "潜在启动"
|
||||
system_prompt = (
|
||||
MOMENTUM_ANALYSIS_PROMPT
|
||||
if rec.strategy == "momentum"
|
||||
else POTENTIAL_ANALYSIS_PROMPT
|
||||
)
|
||||
strategy_label = "趋势突破"
|
||||
signal_map = {"breakout": "突破型", "pullback": "回踩型", "launch": "启动型", "none": "无信号"}
|
||||
entry_label = signal_map.get(rec.entry_signal_type, "无信号")
|
||||
system_prompt = TREND_BREAKOUT_ANALYSIS_PROMPT
|
||||
|
||||
user_msg = _build_user_message(
|
||||
rec=rec,
|
||||
strategy_label=strategy_label,
|
||||
entry_label=entry_label,
|
||||
market_temp=market_temp,
|
||||
temp_level=temp_level,
|
||||
sectors_text=sectors_text,
|
||||
@ -169,6 +165,7 @@ async def _fetch_stock_data(ts_code: str, sector: str) -> str:
|
||||
def _build_user_message(
|
||||
rec,
|
||||
strategy_label: str,
|
||||
entry_label: str,
|
||||
market_temp,
|
||||
temp_level: str,
|
||||
sectors_text: str,
|
||||
@ -179,6 +176,7 @@ def _build_user_message(
|
||||
- 股票: {rec.name}({rec.ts_code})
|
||||
- 所属板块: {rec.sector}
|
||||
- 策略类型: {strategy_label}
|
||||
- 入场信号: {entry_label}
|
||||
- 综合评分: {rec.score}分({rec.level})
|
||||
- 各维度: 市场{rec.market_temp_score} | 板块{rec.sector_score} | 资金{rec.capital_score} | 技术{rec.technical_score} | 位置{rec.position_score} | 估值{rec.valuation_score}
|
||||
- 信号: {rec.signal}
|
||||
|
||||
@ -54,63 +54,42 @@ CHAT_SYSTEM_PROMPT = """\
|
||||
|
||||
# ── AI 分析 Agent Prompt ──
|
||||
|
||||
MOMENTUM_ANALYSIS_PROMPT = """\
|
||||
你是一位专业的 A 股趋势交易分析师。你需要评估一只处于热门板块中的强势股是否值得追入。
|
||||
TREND_BREAKOUT_ANALYSIS_PROMPT = """\
|
||||
你是一位专业的 A 股趋势突破交易分析师。你需要评估一只处于上升趋势中、即将突破的股票的入场时机。
|
||||
|
||||
系统已为你提供了该股票的量化评分、K线数据、资金流向、技术信号、板块数据等详细信息,请基于这些数据进行深度分析。
|
||||
|
||||
重点关注:
|
||||
1. 当前趋势的持续性:量价是否配合?资金流入是否持续?
|
||||
2. 追入的安全性:当前位置高低?短期是否过热?
|
||||
3. 入场时机:应该回调到支撑位买入,还是突破追入?
|
||||
4. 风险收益比:上行目标空间 vs 下行止损空间
|
||||
1. 入场信号类型确认:突破型/回踩型/启动型,信号是否可靠?
|
||||
2. 量价配合:上涨放量、回调缩量的特征是否明显?
|
||||
3. 资金持续性:主力资金是否持续流入3天以上?
|
||||
4. 1-5日操作策略:最佳入场价位、目标价位(2-5%空间)、止损价位
|
||||
5. 时机判断:预计突破/反弹在1-3天内发生的概率
|
||||
|
||||
请严格按以下格式输出分析报告:
|
||||
|
||||
### 核心逻辑
|
||||
(1-2句核心投资逻辑,说明为什么值得关注)
|
||||
### 信号类型确认
|
||||
(突破型/回踩型/启动型,判断依据,可靠性评估)
|
||||
|
||||
### 趋势分析
|
||||
(均线排列、MACD状态、成交量变化趋势,用数据说话)
|
||||
### 量价分析
|
||||
(成交量变化趋势、供需关系、资金介入程度)
|
||||
|
||||
### 入场策略
|
||||
(建议的入场价位和方式:回调买入/突破买入/分批建仓)
|
||||
### 操作策略(1-5日)
|
||||
(入场价位、分批建仓计划、目标价位、止损价位)
|
||||
|
||||
### 时间窗口
|
||||
(预计启动时间、关键观察节点)
|
||||
|
||||
### 风险提示
|
||||
(主要风险因素:板块衰退、大盘系统性风险、量能不济等)
|
||||
(主要风险因素:假突破风险、板块衰退、大盘系统性风险等)
|
||||
|
||||
### AI 评分
|
||||
(给出 1-10 分,格式为纯数字,如:7)
|
||||
(给出 1-10 分,格式为纯数字,如:8)
|
||||
"""
|
||||
|
||||
POTENTIAL_ANALYSIS_PROMPT = """\
|
||||
你是一位专业的 A 股底部反转交易分析师。你需要评估一只处于底部的股票是否具备启动条件。
|
||||
|
||||
系统已为你提供了该股票的量化评分、K线数据、资金流向、技术信号、板块数据等详细信息,请基于这些数据进行深度分析。
|
||||
|
||||
重点关注:
|
||||
1. 底部信号的可信度:量价配合如何?多个技术指标是否共振?
|
||||
2. 可能的催化剂:板块轮动机会?资金是否有流入迹象?技术面是否接近突破?
|
||||
3. 时间窗口:底部蓄势了多久?均线是否收敛?何时可能启动?
|
||||
4. 风险:继续下行的概率和幅度?是否有基本面隐患?
|
||||
|
||||
请严格按以下格式输出分析报告:
|
||||
|
||||
### 底部信号分析
|
||||
(底部形态特征、量价变化、技术指标状态)
|
||||
|
||||
### 催化剂判断
|
||||
(可能的上涨催化剂:板块轮动、资金流入、技术突破等)
|
||||
|
||||
### 埋伏策略
|
||||
(建议的建仓方式和价位、仓位建议、等待确认信号)
|
||||
|
||||
### 风险提示
|
||||
(主要风险因素:继续下行风险、底部无效风险、时间成本等)
|
||||
|
||||
### AI 评分
|
||||
(给出 1-10 分,格式为纯数字,如:6)
|
||||
"""
|
||||
# 保留旧 prompt 用于向后兼容(旧推荐数据仍可能使用)
|
||||
MOMENTUM_ANALYSIS_PROMPT = TREND_BREAKOUT_ANALYSIS_PROMPT
|
||||
POTENTIAL_ANALYSIS_PROMPT = TREND_BREAKOUT_ANALYSIS_PROMPT
|
||||
|
||||
ANALYSIS_USER_TEMPLATE = """\
|
||||
## 量化系统数据
|
||||
|
||||
Binary file not shown.
@ -1,5 +1,10 @@
|
||||
{
|
||||
"pages": {
|
||||
"/sectors/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/sectors/page.js"
|
||||
],
|
||||
"/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
@ -11,10 +16,10 @@
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/page.js"
|
||||
],
|
||||
"/stock/[code]/page": [
|
||||
"/recommendations/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/stock/[code]/page.js"
|
||||
"static/chunks/app/recommendations/page.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,7 @@
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [
|
||||
"static/chunks/react-refresh.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_buildManifest.js",
|
||||
@ -15,16 +13,7 @@
|
||||
"static/chunks/main-app.js"
|
||||
],
|
||||
"pages": {
|
||||
"/_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"
|
||||
]
|
||||
"/_app": []
|
||||
},
|
||||
"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",
|
||||
"/page": "app/page.js"
|
||||
"/recommendations/page": "app/recommendations/page.js",
|
||||
"/page": "app/page.js",
|
||||
"/sectors/page": "app/sectors/page.js"
|
||||
}
|
||||
@ -2,9 +2,7 @@ self.__BUILD_MANIFEST = {
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [
|
||||
"static/chunks/react-refresh.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [],
|
||||
"rootMainFiles": [
|
||||
@ -12,16 +10,7 @@ self.__BUILD_MANIFEST = {
|
||||
"static/chunks/main-app.js"
|
||||
],
|
||||
"pages": {
|
||||
"/_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"
|
||||
]
|
||||
"/_app": []
|
||||
},
|
||||
"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": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "b90zeeYzMcSqNivimWn6T2PQQNxojiQrSeFlOxUaP4Q="
|
||||
"encryptionKey": "IAps6Nn+QS9GVH+sOr6laVRGfUJrD0aLxGy9A/+XkIs="
|
||||
}
|
||||
@ -125,7 +125,7 @@
|
||||
/******/
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.h = () => ("64fc734a2bef9f33")
|
||||
/******/ __webpack_require__.h = () => ("a035f49818643978")
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* 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 SectorHeatmap from "@/components/sector-heatmap";
|
||||
import { useWebSocket } from "@/hooks/use-websocket";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
interface ScanStatus {
|
||||
is_trading: boolean;
|
||||
@ -15,6 +16,7 @@ interface ScanStatus {
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
const [data, setData] = useState<LatestResult | null>(null);
|
||||
const [sectors, setSectors] = useState<SectorData[]>([]);
|
||||
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
|
||||
@ -114,6 +116,7 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{user?.role === "admin" && (
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
@ -130,6 +133,7 @@ export default function DashboardPage() {
|
||||
"立即扫描"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scan result toast */}
|
||||
@ -167,7 +171,7 @@ export default function DashboardPage() {
|
||||
<div className="glass-card-static p-10 text-center">
|
||||
<div className="text-text-muted text-sm mb-1">暂无推荐</div>
|
||||
<div className="text-text-muted/60 text-xs">
|
||||
点击 {scanStatus?.is_trading ? "「盘中扫描」" : "「立即扫描」"} 开始分析
|
||||
{user?.role === "admin" ? `点击 ${scanStatus?.is_trading ? "「盘中扫描」" : "「立即扫描」"} 开始分析` : "等待系统自动扫描更新"}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -2,12 +2,26 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
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 { 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() {
|
||||
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 [llmEnabled, setLlmEnabled] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@ -15,12 +29,22 @@ export default function RecommendationsPage() {
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [result, health] = await Promise.all([
|
||||
fetchAPI<LatestResult>("/api/recommendations/latest"),
|
||||
const [history, health] = await Promise.all([
|
||||
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
||||
fetchAPI<{ llm_enabled: boolean }>("/api/health"),
|
||||
]);
|
||||
setData(result);
|
||||
setDayGroups(history);
|
||||
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) {
|
||||
console.error("加载推荐失败:", e);
|
||||
}
|
||||
@ -59,17 +83,26 @@ export default function RecommendationsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const recs = data?.recommendations ?? [];
|
||||
const filtered =
|
||||
filter === "all"
|
||||
? recs
|
||||
: filter === "buy"
|
||||
? recs.filter((r) => r.signal === "BUY")
|
||||
: filter === "momentum"
|
||||
? recs.filter((r) => r.strategy === "momentum")
|
||||
: filter === "potential"
|
||||
? recs.filter((r) => r.strategy === "potential")
|
||||
: recs.filter((r) => r.level === filter);
|
||||
const toggleDay = (date: string) => {
|
||||
setExpandedDays((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(date)) next.delete(date);
|
||||
else next.add(date);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const applyFilter = (recs: DayGroup["recommendations"]) => {
|
||||
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 (
|
||||
<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>
|
||||
<h1 className="text-lg font-bold tracking-tight">推荐列表</h1>
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
{[
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "momentum", label: "强中选强" },
|
||||
{ key: "potential", label: "潜在启动" },
|
||||
{ key: "breakout", label: "突破型" },
|
||||
{ key: "pullback", label: "回踩型" },
|
||||
{ key: "launch", label: "启动型" },
|
||||
{ key: "buy", label: "买入信号" },
|
||||
{ key: "强烈推荐", label: "强烈推荐" },
|
||||
{ key: "推荐", label: "推荐" },
|
||||
{ key: "观望", label: "观望" },
|
||||
].map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
@ -130,18 +164,96 @@ export default function RecommendationsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
{/* Day groups */}
|
||||
{dayGroups.length === 0 ? (
|
||||
<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/50 text-xs">尝试切换筛选条件或触发新的扫描</div>
|
||||
<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">
|
||||
{filtered.map((rec, i) => (
|
||||
<div key={rec.ts_code} className="animate-fade-in-up" style={{ animationDelay: `${i * 60}ms` }}>
|
||||
<StockCard rec={rec} showLLMLoading={llmEnabled} />
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-4">
|
||||
{dayGroups.map((group, gi) => {
|
||||
const filtered = applyFilter(group.recommendations);
|
||||
if (filter !== "all" && filtered.length === 0) return null;
|
||||
|
||||
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>
|
||||
|
||||
@ -2,10 +2,186 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { fetchAPI } from "@/lib/api";
|
||||
import type { SectorData } from "@/lib/api";
|
||||
import SectorHeatmap from "@/components/sector-heatmap";
|
||||
import type { SectorData, LeadingStock } from "@/lib/api";
|
||||
import { formatNumber } from "@/lib/utils";
|
||||
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() {
|
||||
const [sectors, setSectors] = useState<SectorData[]>([]);
|
||||
|
||||
@ -22,20 +198,38 @@ export default function SectorsPage() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// WebSocket 触发刷新(扫描完成时更新)
|
||||
useWebSocket(
|
||||
useCallback(() => {
|
||||
loadData();
|
||||
}, [loadData])
|
||||
);
|
||||
|
||||
const hasRealtime = sectors.some((s) => s.is_realtime);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
||||
<div className="mb-5 animate-fade-in-up">
|
||||
<h1 className="text-lg font-bold tracking-tight">板块分析</h1>
|
||||
<p className="text-xs text-text-muted mt-0.5">基于资金流向和涨跌表现的热度排名</p>
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
||||
<div className="flex items-center justify-between mb-5 animate-fade-in-up">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,16 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { getLevelBadge, getSignalColor, getScoreColor } from "@/lib/utils";
|
||||
import type { RecommendationData } from "@/lib/api";
|
||||
|
||||
export default function StockCard({ rec, showLLMLoading = false }: { rec: RecommendationData; showLLMLoading?: boolean }) {
|
||||
const badge = getLevelBadge(rec.level);
|
||||
const [aiExpanded, setAiExpanded] = useState(false);
|
||||
|
||||
// 策略标签
|
||||
const strategyLabel = rec.strategy === "potential" ? "潜在启动" : "强中选强";
|
||||
const strategyStyle = rec.strategy === "potential"
|
||||
? "bg-cyan-500/15 text-cyan-400 border-cyan-500/20"
|
||||
: "bg-amber-500/15 text-amber-400 border-amber-500/20";
|
||||
// 入场信号标签
|
||||
const signalTypeMap: Record<string, { label: string; style: string }> = {
|
||||
breakout: { label: "突破型", style: "bg-red-500/15 text-red-400 border-red-500/20" },
|
||||
pullback: { label: "回踩型", style: "bg-blue-500/15 text-blue-400 border-blue-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 (
|
||||
<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}`}>
|
||||
{rec.level}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium border ${strategyStyle}`}>
|
||||
{strategyLabel}
|
||||
</span>
|
||||
{tag && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium border ${tag.style}`}>
|
||||
{tag.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<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}
|
||||
@ -79,24 +92,34 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AI Analysis */}
|
||||
{/* AI Analysis — collapsible */}
|
||||
{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="flex items-center justify-between mb-1.5">
|
||||
<div className="text-xs text-accent-cyan/80 font-semibold tracking-wider">AI 分析</div>
|
||||
<div className="mt-3">
|
||||
<button
|
||||
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 && (
|
||||
<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"}`}>
|
||||
{rec.llm_score}
|
||||
</span>
|
||||
<span className="text-text-muted/50">/10</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary leading-relaxed whitespace-pre-line">
|
||||
<MarkdownText text={rec.llm_analysis} />
|
||||
</div>
|
||||
</button>
|
||||
{aiExpanded && (
|
||||
<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">
|
||||
<MarkdownText text={rec.llm_analysis} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : rec.llm_analysis === "AI 分析暂时不可用" ? (
|
||||
<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;
|
||||
reasons: string[];
|
||||
risk_note: string;
|
||||
strategy?: "momentum" | "potential";
|
||||
strategy?: "momentum" | "potential" | "trend_breakout";
|
||||
entry_signal_type?: "breakout" | "pullback" | "launch" | "none";
|
||||
llm_analysis?: string;
|
||||
llm_score?: number | null;
|
||||
scan_session: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface LeadingStock {
|
||||
ts_code: string;
|
||||
name: string;
|
||||
pct_chg: number;
|
||||
amount: number;
|
||||
limit_times?: number;
|
||||
}
|
||||
|
||||
export interface SectorData {
|
||||
sector_code: string;
|
||||
sector_name: string;
|
||||
@ -124,6 +133,13 @@ export interface SectorData {
|
||||
limit_up_count: number;
|
||||
days_continuous: 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_limit_up_count?: number | null;
|
||||
is_realtime?: boolean;
|
||||
@ -134,6 +150,14 @@ export interface LatestResult {
|
||||
recommendations: RecommendationData[];
|
||||
}
|
||||
|
||||
export interface DayGroup {
|
||||
date: string;
|
||||
count: number;
|
||||
buy_count: number;
|
||||
avg_score: number;
|
||||
recommendations: RecommendationData[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user