This commit is contained in:
aaron 2026-04-10 23:38:37 +08:00
parent ffe36b4055
commit f7fca2e0b9
44 changed files with 2162 additions and 460 deletions

View 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

View File

@ -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

View File

@ -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

View File

@ -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:

View 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)

View File

@ -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)

View File

@ -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]
]

View File

@ -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"

View File

@ -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 = ""

View File

@ -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(

View File

@ -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()),

View File

@ -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 "",
))

View File

@ -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)

View File

@ -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}

View File

@ -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.

View File

@ -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"
]
}
}

View File

@ -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": []
}

File diff suppressed because one or more lines are too long

View File

@ -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"
]
}
}
{}

View File

@ -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"
}

View File

@ -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": []
};

View File

@ -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="{}"

View File

@ -1,5 +1 @@
{
"/_app": "pages/_app.js",
"/_error": "pages/_error.js",
"/_document": "pages/_document.js"
}
{}

View File

@ -1,5 +1,5 @@
{
"node": {},
"edge": {},
"encryptionKey": "b90zeeYzMcSqNivimWn6T2PQQNxojiQrSeFlOxUaP4Q="
"encryptionKey": "IAps6Nn+QS9GVH+sOr6laVRGfUJrD0aLxGy9A/+XkIs="
}

View File

@ -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

View File

@ -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>
) : (

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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">

View File

@ -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;