update fix bug

This commit is contained in:
aaron 2026-04-15 23:32:22 +08:00
parent 5205fbd8a8
commit 5075fcc588
29 changed files with 981 additions and 241 deletions

View File

@ -80,11 +80,11 @@ def classify_entry_signal(df: pd.DataFrame) -> dict:
def detect_breakout(df: pd.DataFrame) -> dict | None:
"""突破型检测
"""突破型检测(严格版:必须真正突破 + 显著放量)
条件
1. 价格突破或贴近 20 日阻力位close >= resist * 0.98
2. 量能放大 (vol > vol_ma5 * 1.2)
1. 收盘价真正突破 20 日高点close > resist_20d不再允许"贴近"
2. 量能显著放大 (vol > vol_ma5 * 1.5)
3. MA5 > MA20短期偏多
4. 收盘偏强 (close 在当日上半部)
"""
@ -100,13 +100,12 @@ def detect_breakout(df: pd.DataFrame) -> dict | None:
return None
resist_20d = df["high"].iloc[-21:-1].max()
# 放宽:贴近阻力位 2% 即视为突破前兆
if last["close"] < resist_20d * 0.98:
# 严格:必须真正突破(收盘价 > 20日高点不允许"贴近未突破"
if last["close"] <= resist_20d:
return None
# 量能放大
min_vol_ratio = getattr(settings, "breakout_min_volume_ratio", 1.2)
if last["vol"] <= last["vol_ma5"] * min_vol_ratio:
# 量能显著放大(从 1.2 提高到 1.5
if last["vol"] <= last["vol_ma5"] * 1.5:
return None
# MA5 > MA20
@ -124,6 +123,7 @@ def detect_breakout(df: pd.DataFrame) -> dict | None:
"signal_type": EntrySignal.BREAKOUT,
"details": {
"resist_level": round(resist_20d, 2),
"resistance_price": round(resist_20d, 2), # 阻力位价格,供止损计算用
"breakout_pct": round(breakout_pct, 2),
"volume_ratio": round(last["vol"] / last["vol_ma5"], 2),
},
@ -404,8 +404,7 @@ def _score_breakout(df: pd.DataFrame, signal: dict) -> float:
score += 18
elif breakout_pct > 0:
score += 12
else:
score += 6 # 贴近未突破
# 不再有"贴近未突破"分支,只有真正突破才到这里
vol_ratio = signal["details"].get("volume_ratio", 1)
if vol_ratio > 2.5:

View File

@ -51,6 +51,7 @@ async def get_latest():
"reasons": r.reasons,
"risk_note": r.risk_note,
"llm_analysis": r.llm_analysis,
"entry_timing": r.entry_timing,
"llm_score": r.llm_score,
"strategy": r.strategy,
"entry_signal_type": r.entry_signal_type,

View File

@ -30,8 +30,8 @@ class Settings(BaseSettings):
# 筛选参数
top_sector_count: int = 5 # 关注板块数量
top_stock_count: int = 20 # 进入技术面筛选的个股数
min_turnover_rate: float = 3.0 # 最小换手率 %
max_turnover_rate: float = 15.0 # 最大换手率 %
min_turnover_rate: float = 2.0 # 最小换手率 %
max_turnover_rate: float = 30.0 # 最大换手率 %
min_circ_mv: float = 50.0 # 最小流通市值(亿)
max_circ_mv: float = 500.0 # 最大流通市值(亿)
min_list_days: int = 60 # 最小上市天数

View File

@ -0,0 +1,201 @@
"""东方财富分钟K线数据客户端
通过 push2his.eastmoney.com 获取 A 股分钟级 K 线数据
用于盘中量能分布分析和短线进场时机判断
免费接口无需认证注意限频
"""
import logging
import httpx
import pandas as pd
from datetime import datetime
from app.data.cache import cache
from app.config import settings
logger = logging.getLogger(__name__)
# 东方财富分钟K线接口
EASTMONEY_KLINE_URL = "http://push2his.eastmoney.com/api/qt/stock/kline/get"
HEADERS = {
"Referer": "http://finance.eastmoney.com",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
}
def _ts_code_to_eastmoney(ts_code: str) -> str:
"""600519.SH -> 1.600519 (上海=1, 深圳=0)"""
code, market = ts_code.split(".")
prefix = "1" if market == "SH" else "0"
return f"{prefix}.{code}"
async def get_min_kline(
ts_code: str,
period: str = "5",
count: int = 48,
) -> pd.DataFrame:
"""获取分钟级 K 线数据
Args:
ts_code: Tushare 格式代码 600519.SH
period: 分钟周期 ("1", "5", "15", "30", "60")
count: 请求条数5分钟×48=4小时一个交易日
Returns:
DataFrame with columns: time, open, close, high, low, volume, amount
DataFrame if failed
"""
cache_key = f"min_kline:{ts_code}:{period}"
cached = cache.get(cache_key)
if cached is not None:
return cached
secid = _ts_code_to_eastmoney(ts_code)
params = {
"secid": secid,
"fields1": "f1,f2,f3,f4,f5,f6",
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61",
"klt": period, # 分钟周期
"fqt": "1", # 前复权
"beg": "0",
"end": "20500101",
"lmt": str(count), # 返回条数限制
}
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
EASTMONEY_KLINE_URL,
params=params,
headers=HEADERS,
timeout=10,
)
data = resp.json()
klines = data.get("data", {}).get("klines", [])
if not klines:
logger.debug(f"东方财富分钟K线 {ts_code} 无数据")
return pd.DataFrame()
# 解析:格式 "2026-04-15 09:30,5.71,5.73,5.74,5.70,12345,70000.00"
rows = []
for line in klines:
parts = line.split(",")
if len(parts) < 7:
continue
rows.append({
"time": parts[0],
"open": float(parts[1]),
"close": float(parts[2]),
"high": float(parts[3]),
"low": float(parts[4]),
"volume": float(parts[5]),
"amount": float(parts[6]),
})
df = pd.DataFrame(rows)
if df.empty:
return df
# 缓存分钟数据盘中3分钟过期盘后30分钟过期
ttl = 180 if _is_trading_hours() else 1800
cache.set(cache_key, df, ttl)
return df
except Exception as e:
logger.error(f"东方财富分钟K线获取失败 {ts_code}: {e}")
return pd.DataFrame()
def analyze_intraday_volume_distribution(min_df: pd.DataFrame) -> dict:
"""分析盘中量能分布基于5分钟K线
Returns:
{
"morning_volume_ratio": float, # 上午成交额占比
"afternoon_volume_ratio": float, # 下午成交额占比
"opening_strength": float, # 开盘30分钟成交额/全天占比
"closing_strength": float, # 尾盘30分钟成交额/全天占比
"volume_trend": str, # "morning_dominant" / "afternoon_dominant" / "balanced"
"key_periods": list[str], # 关键放量时段描述
}
"""
if min_df.empty or len(min_df) < 4:
return {
"morning_volume_ratio": 0.5,
"afternoon_volume_ratio": 0.5,
"opening_strength": 0.25,
"closing_strength": 0.25,
"volume_trend": "balanced",
"key_periods": [],
}
total_amount = min_df["amount"].sum()
if total_amount == 0:
return {
"morning_volume_ratio": 0.5,
"afternoon_volume_ratio": 0.5,
"opening_strength": 0.25,
"closing_strength": 0.25,
"volume_trend": "balanced",
"key_periods": [],
}
# 按时间段分割
morning = min_df[min_df["time"].str.contains("09:|10:|11:0", na=False)]
afternoon = min_df[min_df["time"].str.contains("13:|14:0|14:1|14:2|14:3|14:4|14:5", na=False)]
morning_ratio = morning["amount"].sum() / total_amount if not morning.empty else 0.5
afternoon_ratio = afternoon["amount"].sum() / total_amount if not afternoon.empty else 0.5
# 开盘30分钟9:30-10:00
opening_30 = min_df[min_df["time"].str.contains("09:3|09:4|09:5|10:0", na=False)]
opening_strength = opening_30["amount"].sum() / total_amount if not opening_30.empty else 0.25
# 尾盘30分钟14:30-15:00
closing_30 = min_df[min_df["time"].str.contains("14:3|14:4|14:5|15:0", na=False)]
closing_strength = closing_30["amount"].sum() / total_amount if not closing_30.empty else 0.25
# 量能趋势判断
if morning_ratio > 0.6:
volume_trend = "morning_dominant"
elif afternoon_ratio > 0.6:
volume_trend = "afternoon_dominant"
else:
volume_trend = "balanced"
# 关键放量时段
key_periods = []
avg_amount = min_df["amount"].mean()
hot_bars = min_df[min_df["amount"] > avg_amount * 2]
for _, bar in hot_bars.iterrows():
time_str = bar["time"].split(" ")[1] if " " in bar["time"] else bar["time"]
pct = bar["amount"] / total_amount * 100
key_periods.append(f"{time_str}放量(占比{pct:.0f}%)")
return {
"morning_volume_ratio": round(morning_ratio, 2),
"afternoon_volume_ratio": round(afternoon_ratio, 2),
"opening_strength": round(opening_strength, 2),
"closing_strength": round(closing_strength, 2),
"volume_trend": volume_trend,
"key_periods": key_periods[:3],
}
def _is_trading_hours() -> bool:
"""简单判断是否在交易时间"""
now = datetime.now()
if now.weekday() >= 5: # 周六周日
return False
hour = now.hour
minute = now.minute
# 9:30 - 11:30 或 13:00 - 15:00
if (hour == 9 and minute >= 30) or (hour == 10) or (hour == 11 and minute <= 30):
return True
if (hour == 13) or (hour == 14) or (hour == 15 and minute == 0):
return True
return False

View File

@ -129,6 +129,7 @@ class Recommendation(BaseModel):
level: str = "" # 强烈推荐/推荐/观望/回避
strategy: str = "trend_breakout" # trend_breakout / momentum(旧) / potential(旧)
entry_signal_type: str = "none" # breakout / pullback / launch / none
entry_timing: str = "" # 进场时机建议(盘中适用)
llm_analysis: str = "" # LLM 深度分析
llm_score: float | None = None # AI 评分 1-10
scan_session: str = ""

View File

@ -60,6 +60,9 @@ class TushareClient:
return cached
result = self._fetch_with_retry(func, *args, **kwargs)
if not result.empty:
# Tushare 返回降序(最新在前),排序为升序(最旧在前)以便分析
if "trade_date" in result.columns:
result = result.sort_values("trade_date").reset_index(drop=True)
cache.set(cache_key, result, ttl)
return result

View File

@ -10,7 +10,6 @@ from app.engine.screener import run_screening
from app.data.models import Recommendation, MarketTemperature, SectorInfo
from app.db.database import get_db
from app.db import tables
from app.config import settings
logger = logging.getLogger(__name__)
@ -37,12 +36,6 @@ async def refresh_recommendations(trade_date: str = None, scan_session: str = "m
# 更新历史推荐跟踪(检查之前推荐的后续表现)
await _update_tracking()
# 异步 AI 深度分析(不阻塞返回)
if settings.deepseek_api_key:
import asyncio
from app.llm.analysis_agent import analyze_recommendations
asyncio.create_task(analyze_recommendations(result))
return result

View File

@ -1,16 +1,20 @@
"""趋势突破统一筛选器(自上而下方案
"""趋势突破统一筛选器(自上而下方案,中短线交易定位
三阶段管道
Step 1: 板块定位 找到有资金流入的热门板块 (3-5)
Step 2: 板块内选股 在热门板块成分股中筛出有资金流入的候选 (30-50)
Step 3: 深度分析 供需 + 价格行为 + 趋势 (10-15只推荐)
Step 3: 深度分析 供需 + 价格行为 + 趋势 + LLM (10-15只推荐)
评分公式供需关系 40% + 价格行为 35% + 趋势 25%
板块和资金流作为前置过滤条件不参与评分
评分公式供需关系 50% + 价格行为 40% + 趋势 10%
板块和资金流作为前置过滤条件板块涨停数作为情绪奖励
风险乘数惩罚取最大而非叠加防过度惩罚奖励可叠加
数据源
- 盘中模式Tushare 日线 + 腾讯实时行情混合
- 盘中模式Tushare 日线 + 腾讯实时行情 + 东方财富5分钟K线
- 盘后模式Tushare 当日完整数据
止损止盈基于市场结构阻力位/支撑MA/近期低点而非固定百分比
"""
import logging
@ -78,6 +82,9 @@ async def run_screening(trade_date: str = None) -> dict:
# ── Step 2: 板块内选股 ──
logger.info("=== Step 2: 板块内选股 ===")
if intraday:
candidates = await intraday_filter_stocks(hot_sectors)
else:
candidates = await _select_from_hot_sectors(hot_sectors, trade_date, intraday)
if not candidates:
@ -98,9 +105,22 @@ async def run_screening(trade_date: str = None) -> dict:
"scan_mode": scan_mode,
}
# ── Step 3 之前:注入腾讯实时价格(防止 Tushare 日线数据过时) ──
if candidates:
try:
from app.data.tencent_client import get_realtime_quotes_batch
codes = [c["ts_code"] for c in candidates if "ts_code" in c]
quotes = await get_realtime_quotes_batch(codes)
for c in candidates:
q = quotes.get(c["ts_code"])
if q and q.price > 0:
c["price"] = q.price
except Exception as e:
logger.warning(f"注入实时价格失败,使用 Tushare 收盘价: {e}")
# ── Step 3: 供需 + 价格行为 + 趋势评分 ──
logger.info("=== Step 3: 深度分析 ===")
recommendations = _build_recommendations(
recommendations = await _build_recommendations(
candidates, market_temp, hot_sectors, market_temp_score, intraday,
)
@ -194,6 +214,16 @@ async def _select_from_hot_sectors(
(basic["turnover_rate"] <= settings.max_turnover_rate)
].copy()
# 严格过滤为空时,放宽换手率条件重试
if filtered_basic.empty:
logger.info("Step 2 严格过滤无结果,放宽换手率重试")
filtered_basic = basic[
(basic["ts_code"].isin(sector_member_codes)) &
(~basic["ts_code"].isin(exclude_codes)) &
(basic["circ_mv"] >= settings.min_circ_mv) &
(basic["circ_mv"] <= settings.max_circ_mv)
].copy()
logger.info(f"Step 2 基本面过滤: {len(sector_member_codes)} 只 → {len(filtered_basic)}")
if filtered_basic.empty:
@ -277,7 +307,7 @@ async def _select_from_hot_sectors(
return candidates
def _build_recommendations(
async def _build_recommendations(
candidates: list[dict],
market_temp: MarketTemperature,
hot_sectors: list[SectorInfo],
@ -310,6 +340,7 @@ def _build_recommendations(
industry_map[row["ts_code"]] = row.get("industry", "")
recommendations = []
llm_candidates = [] # 收集候选摘要供 LLM 分析
total = len(candidates)
signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0}
@ -327,6 +358,14 @@ def _build_recommendations(
if df.empty or len(df) < 30:
continue
# 数据新鲜度校验:最后一行必须是近 10 天内的数据
from datetime import datetime, timedelta
last_date = str(df.iloc[-1]["trade_date"])
cutoff = (datetime.now() - timedelta(days=10)).strftime("%Y%m%d")
if last_date < cutoff:
logger.warning(f"K线数据过时 {ts_code}: 最新={last_date}, 需≥{cutoff}, 跳过")
continue
# 添加技术指标
df = add_all_indicators(df)
@ -340,45 +379,64 @@ def _build_recommendations(
# ── 三维度评分 ──
# 1. 供需关系评分 (40%)
# 1. 供需关系评分 (50%) — 短线核心
supply_demand_score = score_supply_demand(df)
# 2. 价格行为评分 (35%)
# 2. 价格行为评分 (40%) — 形态质量
price_action_score = _score_price_action(df, entry_signal)
# 3. 趋势评分 (25%)
# 3. 趋势评分 (10%) — 短线趋势权重低,偏空直接过滤
trend_score = _score_trend(df)
# 综合评分
# 趋势偏空门槛过滤MA5<MA10<MA20空头排列直接跳过
last = df.iloc[-1]
if all(c in df.columns for c in ["ma5", "ma10", "ma20"]):
if not any(pd.isna(last[c]) for c in ["ma5", "ma10", "ma20"]):
if last["ma5"] < last["ma10"] < last["ma20"]:
signal_counts["none"] += 1
continue
# 综合评分(短线交易:供需最关键,趋势只做门槛)
final_score = (
supply_demand_score * 0.40 +
price_action_score * 0.35 +
trend_score * 0.25
supply_demand_score * 0.50 +
price_action_score * 0.40 +
trend_score * 0.10
)
# 风险乘数
# ── 风险乘数:惩罚取最大而非叠加(避免过度惩罚),奖励可叠加 ──
tech_signal = generate_signals(ts_code, name)
# 收集所有惩罚因子(取最大,而非叠加)
penalties = []
if tech_signal:
if tech_signal.rally_pct_5d > 20:
final_score *= 0.65
penalties.append(0.65)
elif tech_signal.rally_pct_5d > 15:
final_score *= 0.80
penalties.append(0.80)
# 板块末期惩罚(板块信息来自 hot_sectors
sector_stage = _get_sector_stage(sector, hot_sectors)
if sector_stage == "end":
final_score *= 0.70
penalties.append(0.70)
elif sector_stage == "late":
final_score *= 0.88
penalties.append(0.88)
# 市场温度风控
if market_temp_score < 30:
final_score *= 0.75
penalties.append(0.75)
elif market_temp_score < 50:
final_score *= 0.88
penalties.append(0.88)
# 取最大惩罚1.0 = 无惩罚)
if penalties:
final_score *= min(penalties)
# 奖励可叠加(奖励之间互不矛盾)
sector_limit_up = _get_sector_limit_up(sector, hot_sectors)
sector_member_count = _get_sector_member_count(sector, hot_sectors)
if sector_limit_up >= 5:
final_score *= 1.20 # 板块5+涨停,情绪极强
elif sector_limit_up >= 3:
final_score *= 1.10 # 板块3涨停情绪较强
# 高置信度入场信号奖励
if entry_signal.get("signal_score", 0) >= 80:
final_score *= 1.10
@ -397,29 +455,93 @@ def _build_recommendations(
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 = entry_signal.get("details", {})
current_close = stock.get("price") or float(df.iloc[-1]["close"])
st = signal_type.value
if st in ("breakout", "breakout_confirm") and details.get("resist_level"):
entry_price = details["resist_level"]
details = entry_signal.get("details", {})
# ── 入场价:统一为当前价(短线看盘即时进场) ──
entry_price = round(current_close, 2)
# ── 止损价:基于市场结构 ──
if st == "breakout":
# 突破型止损在突破点被突破的阻力位下方1%
resistance = details.get("resistance_price", 0)
if resistance and resistance > 0:
stop_loss = round(resistance * 0.99, 2)
else:
# fallback: 近20日低点下方1%
low_20 = float(df.tail(20)["low"].min())
stop_loss = round(low_20 * 0.99, 2)
elif st == "pullback":
# 回踩型止损在支撑均线下方1.5%
support_ma = details.get("support_ma", "MA20")
support_price = 0
if support_ma == "MA20" and not pd.isna(last.get("ma20")):
support_price = last["ma20"]
elif support_ma == "MA10" and not pd.isna(last.get("ma10")):
support_price = last["ma10"]
if support_price > 0:
stop_loss = round(support_price * 0.985, 2)
else:
stop_loss = round(current_close * 0.97, 2)
elif st == "reversal":
# 反转型止损在近5日最低点下方1%
low_5 = float(df.tail(5)["low"].min())
stop_loss = round(low_5 * 0.99, 2)
elif st == "launch":
# 启动型止损在MA20下方2%
if not pd.isna(last.get("ma20")) and last["ma20"] > 0:
stop_loss = round(last["ma20"] * 0.98, 2)
else:
stop_loss = round(current_close * 0.97, 2)
else:
# breakout_confirm / 其他近20日低点下方1%
low_20 = float(df.tail(20)["low"].min())
stop_loss = round(min(low_20 * 0.99, current_close * 0.97), 2)
# ── 止盈价:基于下一个阻力位 ──
# 近20日高点作为第一阻力
high_20 = float(df.tail(20)["high"].max())
# 近60日高点作为第二阻力
high_60 = float(df.tail(60)["high"].max()) if len(df) >= 60 else high_20
if st == "breakout":
# 突破型刚突破20日高点目标看60日高点附近
if high_60 > current_close:
target_price = round(min(high_60 * 0.98, entry_price * 1.08), 2)
else:
target_price = round(entry_price * 1.05, 2)
elif st == "pullback" and details.get("support_price"):
entry_price = details["support_price"]
target_price = round(entry_price * 1.05, 2)
elif st == "launch" and details.get("resist_level"):
entry_price = round(details["resist_level"] * 1.01, 2)
target_price = round(details["resist_level"] * 1.08, 2)
elif st == "reversal" and tech_signal and tech_signal.support_price:
entry_price = tech_signal.support_price
target_price = round(entry_price * 1.08, 2)
elif st == "launch":
# 启动型:整理后启动,目标看整理区间上方+8%
target_price = round(min(high_20 * 1.03, entry_price * 1.08), 2)
elif st == "reversal":
# 反转型从低位反转目标看近20日高点
target_price = round(min(high_20 * 0.98, entry_price * 1.08), 2)
elif st == "pullback":
# 回踩型:目标看前高附近
target_price = round(min(high_20 * 0.98, entry_price * 1.05), 2)
else:
# breakout_confirm / 其他
target_price = round(min(high_20 * 0.98, entry_price * 1.05), 2)
# 保底:止损不超过入场价-8%(防止结构化止损太远)
max_stop_pct = 0.08
if stop_loss < entry_price * (1 - max_stop_pct):
stop_loss = round(entry_price * (1 - max_stop_pct), 2)
# 止损不低于入场价-2%(止损太近没有意义)
min_stop_pct = 0.02
if stop_loss > entry_price * (1 - min_stop_pct):
stop_loss = round(entry_price * (1 - min_stop_pct), 2)
# 保底:止盈不低于入场价+3%(空间太小不值得做)
min_target_pct = 0.03
if target_price < entry_price * (1 + min_target_pct):
target_price = round(entry_price * (1 + min_target_pct), 2)
# 生成推荐理由
reasons = _generate_reasons(stock, entry_signal, tech_signal, df, intraday)
@ -428,6 +550,9 @@ def _build_recommendations(
# 量价模式
vol_pattern = analyze_volume_pattern(df)
# 进场时机建议(盘中适用)
entry_timing = _generate_entry_timing(signal_type.value, intraday)
rec = Recommendation(
ts_code=ts_code,
name=name,
@ -448,11 +573,45 @@ def _build_recommendations(
level=level,
strategy="trend_breakout",
entry_signal_type=signal_type.value,
entry_timing=entry_timing,
)
recommendations.append(rec)
if len(recommendations) >= settings.top_stock_count:
break
# 收集 LLM 分析所需的候选摘要(不含 signal_type让 LLM 独立判断)
llm_candidate = {
"ts_code": ts_code,
"name": name,
"sector": sector,
"quant_score": round(final_score, 1),
"position_score": round(position_score, 1),
"current_price": stock.get("price") or float(df.iloc[-1]["close"]),
"kline_summary": _summarize_for_llm(df, entry_signal, tech_signal),
"capital_flow_summary": (
f"主力净流入{stock.get('main_net_inflow', 0):.0f}万, "
f"占比{stock.get('inflow_ratio', 0):.1f}%"
),
}
# 盘中模式:补充分时量能分布数据
if intraday:
try:
from app.data.eastmoney_client import get_min_kline, analyze_intraday_volume_distribution
min_df = await get_min_kline(ts_code, period="5", count=48)
if not min_df.empty:
vol_dist = analyze_intraday_volume_distribution(min_df)
llm_candidate["intraday_volume"] = (
f"上午量占比{vol_dist['morning_volume_ratio']}%, "
f"下午{vol_dist['afternoon_volume_ratio']}%, "
f"开盘30分{vol_dist['opening_strength']}%, "
f"尾盘30分{vol_dist['closing_strength']}%, "
f"趋势={vol_dist['volume_trend']}"
)
if vol_dist["key_periods"]:
llm_candidate["intraday_volume"] += f", 放量时段: {'; '.join(vol_dist['key_periods'])}"
except Exception as e:
logger.debug(f"分时量能数据获取失败 {ts_code}: {e}")
llm_candidates.append(llm_candidate)
except Exception as e:
logger.debug(f"深度分析 {ts_code} 失败: {e}")
@ -469,6 +628,61 @@ def _build_recommendations(
f"(共分析{total}只)"
)
# ── LLM 逐股深度分析 ──
if settings.deepseek_api_key and llm_candidates:
try:
from app.llm.batch_screener import analyze_candidates_individually
# 只对量化评分 Top N 做LLM分析减少API调用
llm_candidates.sort(key=lambda c: c["quant_score"], reverse=True)
llm_top = llm_candidates[:settings.top_stock_count]
market_summary = (
f"市场温度: {market_temp.temperature}/100, "
f"涨跌比: {market_temp.up_count}涨/{market_temp.down_count}跌, "
f"涨停: {market_temp.limit_up_count}"
)
llm_results = await analyze_candidates_individually(llm_top, market_summary)
# 综合量化 + LLM 判断
for rec in recommendations:
llm_data = llm_results.get(rec.ts_code)
if llm_data:
rec.llm_analysis = llm_data.get("analysis", "")
# LLM 信号强度转换为分数调整
strength = llm_data.get("strength", "")
llm_signal = llm_data.get("signal", "HOLD")
# 基础调整:量化评分 * 调整系数
if llm_signal == "SKIP":
adjustment = 0.50
elif llm_signal == "HOLD":
adjustment = 0.85
elif llm_signal == "BUY" and strength == "":
adjustment = 1.15
elif llm_signal == "BUY" and strength == "":
adjustment = 1.05
else: # BUY + 弱
adjustment = 1.00
rec.score = round(rec.score * adjustment, 1)
rec.level = _score_to_level(rec.score)
# 用 LLM 给出的价格替代硬编码价格
if llm_data.get("entry_price"):
rec.entry_price = llm_data["entry_price"]
if llm_data.get("target_price"):
rec.target_price = llm_data["target_price"]
if llm_data.get("stop_loss"):
rec.stop_loss = llm_data["stop_loss"]
recommendations.sort(key=lambda r: r.score, reverse=True)
recommendations = recommendations[:settings.top_stock_count]
logger.info(f"LLM 逐股分析完成, 综合评分后保留 {len(recommendations)}")
except Exception as e:
logger.error(f"LLM 逐股分析失败, 仅使用量化评分: {e}")
return recommendations
@ -685,6 +899,22 @@ def _get_sector_heat(sector_name: str, hot_sectors: list[SectorInfo]) -> float:
return 30.0
def _get_sector_limit_up(sector_name: str, hot_sectors: list[SectorInfo]) -> int:
"""获取板块涨停数"""
for s in hot_sectors:
if s.sector_name == sector_name:
return s.limit_up_count
return 0
def _get_sector_member_count(sector_name: str, hot_sectors: list[SectorInfo]) -> int:
"""获取板块成分股数量"""
for s in hot_sectors:
if s.sector_name == sector_name:
return s.member_count
return 0
def _score_capital_simple(stock: dict) -> float:
"""资金流简单评分(仅基于已有数据,不额外调 API"""
main_net = stock.get("main_net_inflow", 0) or 0
@ -712,6 +942,21 @@ def _score_capital_simple(stock: dict) -> float:
return min(score, 100)
def _generate_entry_timing(signal_type: str, intraday: bool) -> str:
"""根据信号类型生成进场时机建议"""
if not intraday:
return "" # 盘后模式不需要时机建议
timing_map = {
"breakout": "开盘观察是否站稳突破位午后14:00确认不回落再进场",
"breakout_confirm": "突破已确认,盘中放量时可直接进场",
"pullback": "盘中靠近支撑位时分批进场尾盘14:30确认支撑有效可加仓",
"launch": "早盘放量确认后即可进场注意开盘9:30-10:00量能",
"reversal": "午后13:30确认不回落再进场避免早盘追高",
}
return timing_map.get(signal_type, "盘中观察量价配合,确认信号后进场")
def _score_to_level(score: float) -> str:
if score >= 80:
return "强烈推荐"
@ -823,3 +1068,141 @@ def _generate_risk_note(
if not notes:
return "注意设好止损,控制仓位"
return "".join(notes)
def _summarize_for_llm(df, entry_signal: dict, tech_signal: TechnicalSignal | None) -> str:
"""生成 K 线分析结论供 LLM 判断(输出结论而非原始数据)"""
import pandas as pd
last = df.iloc[-1]
parts = []
# ── 趋势结论 ──
ma_fields = ["ma5", "ma10", "ma20", "ma60"]
ma_vals = {m: last.get(m) for m in ma_fields}
trend_desc = "趋势不明"
all_ma_valid = all(ma_vals.get(m) is not None and not pd.isna(ma_vals[m]) for m in ma_fields)
if all_ma_valid:
if ma_vals["ma5"] > ma_vals["ma10"] > ma_vals["ma20"] > ma_vals["ma60"]:
trend_desc = "强势多头排列(MA5>MA10>MA20>MA60)"
elif ma_vals["ma5"] > ma_vals["ma10"] > ma_vals["ma20"]:
trend_desc = "中短期多头(MA5>MA10>MA20)"
elif ma_vals["ma5"] > ma_vals["ma20"]:
trend_desc = "偏多(MA5在MA20上方)"
elif ma_vals["ma5"] < ma_vals["ma10"] < ma_vals["ma20"]:
trend_desc = "空头排列,趋势偏弱"
else:
trend_desc = "均线交织,趋势震荡"
# MA20 方向
if len(df) >= 5 and not pd.isna(last.get("ma20")) and not pd.isna(df.iloc[-5].get("ma20")):
ma20_now = last["ma20"]
ma20_5d = df.iloc[-5]["ma20"]
if ma20_5d > 0:
ma20_pct = (ma20_now - ma20_5d) / ma20_5d * 100
if ma20_pct > 2:
trend_desc += "MA20快速上扬"
elif ma20_pct > 0:
trend_desc += "MA20缓慢上行"
else:
trend_desc += "MA20走平或下行"
parts.append(trend_desc)
# ── 量价结论 ──
if len(df) >= 10:
recent = df.tail(10)
up_days = recent[recent["pct_chg"] > 0]
down_days = recent[recent["pct_chg"] <= 0]
vol_conclusion = ""
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 > 1.5:
vol_conclusion = f"量价健康(上涨均量/下跌均量={ratio:.1f},需求主导)"
elif ratio > 1.0:
vol_conclusion = f"量价尚可(量比={ratio:.1f},需求略强)"
else:
vol_conclusion = f"量价偏弱(量比={ratio:.1f},供给主导)"
if not vol_conclusion:
vol_conclusion = "量价数据不足"
# 最近5日量能变化
recent_5 = df.tail(5)
vol_ma5 = recent_5["vol"].mean()
vol_ma10 = df.tail(10)["vol"].mean()
if vol_ma10 > 0:
vol_ratio = vol_ma5 / vol_ma10
if vol_ratio > 1.5:
vol_conclusion += "近5日明显放量"
elif vol_ratio < 0.7:
vol_conclusion += "近5日缩量"
parts.append(vol_conclusion)
# ── MACD 结论 ──
dif = last.get("dif", 0) or 0
dea = last.get("dea", 0) or 0
macd_desc = ""
if len(df) >= 3:
prev_dif = df.iloc[-2].get("dif", 0) or 0
prev_dea = df.iloc[-2].get("dea", 0) or 0
if dif > dea and prev_dif <= prev_dea:
macd_desc = "MACD刚金叉"
elif dif > dea:
macd_desc = "MACD金叉运行中"
elif dif < dea and prev_dif >= prev_dea:
macd_desc = "MACD刚死叉"
elif dif < dea:
macd_desc = "MACD死叉运行中"
if dif > 0:
macd_desc += ",零轴上方(偏多)"
else:
macd_desc += ",零轴下方(偏空)"
parts.append(macd_desc or "MACD数据不足")
# ── RSI 结论 ──
rsi = last.get("rsi14", 50)
if not pd.isna(rsi):
if rsi > 80:
parts.append(f"RSI14={rsi:.0f},超买区,回调风险大")
elif rsi > 70:
parts.append(f"RSI14={rsi:.0f},偏高,注意追高风险")
elif rsi >= 40:
parts.append(f"RSI14={rsi:.0f},健康区间")
else:
parts.append(f"RSI14={rsi:.0f},偏低,可能超卖")
# ── 价格位置结论 ──
if tech_signal:
pos_parts = []
if tech_signal.rally_pct_5d > 15:
pos_parts.append(f"5日已涨{tech_signal.rally_pct_5d}%,追高风险大")
elif tech_signal.rally_pct_5d > 8:
pos_parts.append(f"5日涨{tech_signal.rally_pct_5d}%,短期有一定涨幅")
elif tech_signal.rally_pct_5d > 0:
pos_parts.append(f"5日涨{tech_signal.rally_pct_5d}%,涨幅温和")
else:
pos_parts.append(f"5日跌{abs(tech_signal.rally_pct_5d)}%,回调中")
if tech_signal.distance_from_high >= 0:
pos_parts.append("处于60日新高附近")
elif tech_signal.distance_from_high > -5:
pos_parts.append(f"距60日高点{abs(tech_signal.distance_from_high):.1f}%")
else:
pos_parts.append(f"距60日高点{abs(tech_signal.distance_from_high):.1f}%,位置较低")
parts.append("位置: " + "".join(pos_parts))
# ── 近5日价格走势简述 ──
if len(df) >= 5:
recent_5 = df.tail(5)
closes = recent_5["close"].tolist()
first_c = closes[0]
last_c = closes[-1]
pct_5d = (last_c - first_c) / first_c * 100
parts.append(f"当前价: {last_c:.2f}5日{'' if pct_5d >= 0 else ''}{abs(pct_5d):.1f}%")
return "\n".join(parts)

View File

@ -0,0 +1,170 @@
"""LLM 逐股深度分析
量化筛选完成后对每只候选股票单独调用 LLM 做深度分析
AI 独立判断入场时机并给出具体买卖价格
"""
import asyncio
import logging
import re
from app.config import settings
logger = logging.getLogger(__name__)
async def analyze_single_stock(candidate: dict, market_summary: str) -> dict:
"""对单只股票做 LLM 深度分析
candidate: 包含 ts_code, name, sector, quant_score, position_score,
kline_summary, capital_flow_summary
market_summary: 市场环境摘要
返回: {
"signal": "BUY"/"HOLD"/"SKIP",
"strength": ""/""/"",
"entry_price": float or None,
"target_price": float or None,
"stop_loss": float or None,
"analysis": str,
}
"""
from app.llm.prompts import SINGLE_STOCK_ANALYSIS_PROMPT
from app.llm.client import get_client
# 构建 prompt — 不传 signal_type让 LLM 独立判断
stock_text = f"""\
股票: {candidate['name']}({candidate['ts_code']})
板块: {candidate.get('sector', '未知')}
量化评分: {candidate.get('quant_score', 0)}/100
位置安全: {candidate.get('position_score', 50)}/100
当前价: {candidate.get('current_price', '未知')}"""
if candidate.get("kline_summary"):
stock_text += f"\n\n## 技术分析结论\n{candidate['kline_summary']}"
if candidate.get("capital_flow_summary"):
stock_text += f"\n\n## 资金流向\n{candidate['capital_flow_summary']}"
user_msg = f"{SINGLE_STOCK_ANALYSIS_PROMPT}\n\n## 市场环境\n{market_summary}\n\n{stock_text}\n\n请给出你的分析。"
try:
client = get_client()
response = await client.chat.completions.create(
model=settings.deepseek_model,
messages=[
{
"role": "system",
"content": (
"你是一位专业的A股趋势交易分析师专注于中短线1-5日交易。"
"你根据技术分析结论独立判断入场时机,给出具体的买卖价格建议。"
"不要被量化评分束缚,给出你真实的判断。"
),
},
{"role": "user", "content": user_msg},
],
max_tokens=800,
temperature=0.3,
)
content = response.choices[0].message.content.strip()
return _parse_single_response(content)
except Exception as e:
logger.error(f"LLM 分析 {candidate.get('ts_code')} 失败: {e}")
return {
"signal": "HOLD",
"strength": "",
"entry_price": None,
"target_price": None,
"stop_loss": None,
"analysis": "AI分析暂不可用",
}
def _parse_single_response(text: str) -> dict:
"""解析单只股票的 LLM 返回"""
# 提取信号
signal = "HOLD"
signal_match = re.search(r"信号[:\s]*(BUY|HOLD|SKIP)", text)
if signal_match:
signal = signal_match.group(1)
# 提取信号强度
strength = ""
strength_match = re.search(r"信号强度[:\s]*(强|中|弱)", text)
if strength_match:
strength = strength_match.group(1)
# 提取买入价
entry_price = None
entry_match = re.search(r"买入价[:\s]*(\d+(?:\.\d+)?)", text)
if entry_match:
entry_price = float(entry_match.group(1))
# 提取止盈价
target_price = None
target_match = re.search(r"止盈价[:\s]*(\d+(?:\.\d+)?)", text)
if target_match:
target_price = float(target_match.group(1))
# 提取止损价
stop_loss = None
stop_match = re.search(r"止损价[:\s]*(\d+(?:\.\d+)?)", text)
if stop_match:
stop_loss = float(stop_match.group(1))
# 提取分析
analysis = ""
analysis_match = re.search(r"分析[:\s]*(.+)", text, re.DOTALL)
if analysis_match:
analysis = analysis_match.group(1).strip()
return {
"signal": signal,
"strength": strength,
"entry_price": entry_price,
"target_price": target_price,
"stop_loss": stop_loss,
"analysis": analysis or "暂无分析",
}
async def analyze_candidates_individually(
candidates: list[dict], market_summary: str, max_concurrent: int = 3
) -> dict[str, dict]:
"""对候选股票逐个做 LLM 分析(控制并发数)
返回: {ts_code: {"signal", "strength", "entry_price", ...}}
"""
if not settings.deepseek_api_key or not candidates:
return {}
results = {}
semaphore = asyncio.Semaphore(max_concurrent)
async def _analyze_with_semaphore(c: dict):
async with semaphore:
ts_code = c["ts_code"]
logger.info(f"LLM 分析: {c.get('name', ts_code)}")
result = await analyze_single_stock(c, market_summary)
logger.info(
f"LLM 结果: {c.get('name', ts_code)}"
f"信号={result['signal']} 强度={result['strength']} "
f"买入={result.get('entry_price')} 止盈={result.get('target_price')} "
f"止损={result.get('stop_loss')}"
)
return ts_code, result
tasks = [_analyze_with_semaphore(c) for c in candidates]
completed = await asyncio.gather(*tasks, return_exceptions=True)
for item in completed:
if isinstance(item, Exception):
logger.error(f"LLM 分析任务异常: {item}")
continue
ts_code, result = item
results[ts_code] = result
logger.info(f"LLM 逐股分析完成: {len(results)}/{len(candidates)}")
return results

View File

@ -113,3 +113,37 @@ ANALYSIS_USER_TEMPLATE = """\
- 涨停: {limit_up_count}
请使用工具获取该股票的K线资金流向技术信号等数据然后按照指定格式输出深度分析报告"""
# ── AI 逐股筛选 Prompt ──
SINGLE_STOCK_ANALYSIS_PROMPT = """\
你是一位专业的A股趋势交易分析师专注于中短线交易持仓1-5个交易日
量化系统已通过多轮筛选认定该股票具备投资价值请你基于以下技术分析结论独立判断入场时机
你的任务
1. 综合趋势量价技术指标和位置判断当前是否适合介入
2. 如果适合介入给出具体的买入价位止盈价和止损价
3. 评估信号强度
注意
- 你看到的是量化系统对K线和技术指标的分析结论不是原始数据
- 请独立判断不要被量化评分影响太多
- 止盈空间通常3-8%止损空间通常3-5%
- 中短线交易重点关注1-5日的走势
请严格按以下格式输出
信号: BUY/HOLD/SKIP
信号强度: //
买入价: XX.XX建议入场价位基于当前价微调
止盈价: XX.XX目标价位给出合理空间
止损价: XX.XX跌破此价离场
分析: 3-5句话说明核心逻辑入场理由和主要风险
说明
- BUY: 看好建议在买入价附近介入
- HOLD: 观望等待更好的时机
- SKIP: 不看好风险大于收益
- 信号强度""表示把握较大""表示不确定性较高"""

Binary file not shown.

View File

@ -1,9 +1,9 @@
{
"pages": {
"/diagnose/page": [
"/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/diagnose/page.js"
"static/chunks/app/page.js"
],
"/layout": [
"static/chunks/webpack.js",
@ -11,15 +11,10 @@
"static/css/app/layout.css",
"static/chunks/app/layout.js"
],
"/page": [
"/stock/[code]/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/page.js"
],
"/sectors/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/sectors/page.js"
"static/chunks/app/stock/[code]/page.js"
],
"/monitor/page": [
"static/chunks/webpack.js",
@ -31,10 +26,15 @@
"static/chunks/main-app.js",
"static/chunks/app/recommendations/page.js"
],
"/_not-found/page": [
"/sectors/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/_not-found/page.js"
"static/chunks/app/sectors/page.js"
],
"/diagnose/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/diagnose/page.js"
]
}
}

View File

@ -2,7 +2,9 @@
"polyfillFiles": [
"static/chunks/polyfills.js"
],
"devFiles": [],
"devFiles": [
"static/chunks/react-refresh.js"
],
"ampDevFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
@ -13,7 +15,16 @@
"static/chunks/main-app.js"
],
"pages": {
"/_app": []
"/_app": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_app.js"
],
"/_error": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_error.js"
]
},
"ampFirstPages": []
}

View File

@ -4,5 +4,17 @@
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
},
"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"
]
}
}

View File

@ -1,8 +1,8 @@
{
"/_not-found/page": "app/_not-found/page.js",
"/diagnose/page": "app/diagnose/page.js",
"/page": "app/page.js",
"/sectors/page": "app/sectors/page.js",
"/stock/[code]/page": "app/stock/[code]/page.js",
"/monitor/page": "app/monitor/page.js",
"/recommendations/page": "app/recommendations/page.js"
"/recommendations/page": "app/recommendations/page.js",
"/sectors/page": "app/sectors/page.js",
"/diagnose/page": "app/diagnose/page.js"
}

View File

@ -2,7 +2,9 @@ self.__BUILD_MANIFEST = {
"polyfillFiles": [
"static/chunks/polyfills.js"
],
"devFiles": [],
"devFiles": [
"static/chunks/react-refresh.js"
],
"ampDevFiles": [],
"lowPriorityFiles": [],
"rootMainFiles": [
@ -10,7 +12,16 @@ self.__BUILD_MANIFEST = {
"static/chunks/main-app.js"
],
"pages": {
"/_app": []
"/_app": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_app.js"
],
"/_error": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_error.js"
]
},
"ampFirstPages": []
};

View File

@ -1 +1 @@
self.__REACT_LOADABLE_MANIFEST="{\"app/sectors/page.tsx -> echarts\":{\"id\":\"app/sectors/page.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}"
self.__REACT_LOADABLE_MANIFEST="{\"app/sectors/page.tsx -> echarts\":{\"id\":\"app/sectors/page.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"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\"]}}"

View File

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

View File

@ -1,5 +1,5 @@
{
"node": {},
"edge": {},
"encryptionKey": "2X6fu/eJ5clwy441ELew8wl+I0z3kZMpUonbMY8jvw8="
"encryptionKey": "ivfiGgdCRmC7fJWUxiqW3o7IY5TIF27ivPa+HF5AgdE="
}

View File

@ -125,7 +125,7 @@
/******/
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("4eddf011ceda7fdd")
/******/ __webpack_require__.h = () => ("53866692304f1888")
/******/ })();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */

File diff suppressed because one or more lines are too long

View File

@ -2,12 +2,10 @@
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { fetchAPI, postAPI } from "@/lib/api";
import type { DiagnosisResult } from "@/lib/api";
import { fetchAPI } from "@/lib/api";
import { getScoreColor } from "@/lib/utils";
import KlineChart from "@/components/kline-chart";
import CapitalFlowChart from "@/components/capital-flow";
import ScoreRadar from "@/components/score-radar";
interface StockSignals {
ts_code: string;
@ -71,8 +69,6 @@ export default function StockDetailPage() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [kline, setKline] = useState<any[]>([]);
const [capitalFlow, setCapitalFlow] = useState<FlowRecord[]>([]);
const [diagnosis, setDiagnosis] = useState<string | null>(null);
const [diagnosing, setDiagnosing] = useState(false);
useEffect(() => {
if (!code) return;
@ -113,11 +109,10 @@ export default function StockDetailPage() {
</a>
{/* Quote + Radar */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 animate-fade-in-up">
{/* Quote header */}
<div className="animate-fade-in-up">
{quote && (
<div className="glass-card-static p-5 md:col-span-2">
<div className="glass-card-static p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<span className="text-lg font-bold tracking-tight">{quote.name}</span>
@ -155,9 +150,9 @@ export default function StockDetailPage() {
<div className="grid grid-cols-4 gap-2 mt-4">
<MiniStat
label="开盘"
value={quote.open?.toFixed(2) ?? "-"}
value={quote.open && quote.open > 0 ? quote.open.toFixed(2) : "-"}
color={
quote.open && quote.pre_close
quote.open && quote.open > 0 && quote.pre_close
? quote.open >= quote.pre_close
? "text-red-400"
: "text-emerald-400"
@ -166,12 +161,12 @@ export default function StockDetailPage() {
/>
<MiniStat
label="最高"
value={quote.high?.toFixed(2) ?? "-"}
value={quote.high && quote.high > 0 ? quote.high.toFixed(2) : "-"}
color="text-red-400"
/>
<MiniStat
label="最低"
value={quote.low?.toFixed(2) ?? "-"}
value={quote.low && quote.low > 0 ? quote.low.toFixed(2) : "-"}
color="text-emerald-400"
/>
<MiniStat
@ -215,13 +210,6 @@ export default function StockDetailPage() {
</div>
</div>
)}
{/* Radar chart - desktop */}
{signals && (
<div className="hidden md:block">
<ScoreRadar signals={signals} />
</div>
)}
</div>
{/* Position Safety + Capital Flow Breakdown */}
@ -324,61 +312,6 @@ export default function StockDetailPage() {
{kline.length > 0 && <KlineChart data={kline} />}
{capitalFlow.length > 0 && <CapitalFlowChart data={capitalFlow} />}
</div>
{/* AI Diagnosis */}
<div className="animate-fade-in-up delay-200">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">AI </h2>
<button
onClick={async () => {
setDiagnosing(true);
setDiagnosis(null);
try {
const res = await postAPI<DiagnosisResult>(`/api/stocks/${code}/diagnose`);
if (res.status === "ok" && res.diagnosis) {
setDiagnosis(res.diagnosis);
}
} catch {
// ignore
} finally {
setDiagnosing(false);
}
}}
disabled={diagnosing}
className="text-xs px-4 py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-40 transition-all duration-200 border border-amber-500/10 font-medium"
>
{diagnosing ? (
<span className="inline-flex items-center gap-1.5">
<span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
...
</span>
) : (
"AI 诊断"
)}
</button>
</div>
{diagnosing && (
<div className="glass-card-static p-8 text-center">
<div className="w-8 h-8 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-3" />
<div className="text-xs text-text-muted">AI {quote?.name || code} ...</div>
</div>
)}
{diagnosis && !diagnosing && (
<div className="glass-card-static p-5">
<div
className="text-xs text-text-secondary leading-relaxed [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-amber-400 [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:first:mt-0 [&_p]:text-text-secondary [&_p]:mb-2 [&_ul]:text-text-secondary [&_li]:mb-1"
dangerouslySetInnerHTML={{ __html: markdownToHtml(diagnosis) }}
/>
</div>
)}
</div>
{/* Mobile radar */}
{signals && (
<div className="md:hidden animate-fade-in-up delay-225">
<ScoreRadar signals={signals} />
</div>
)}
</div>
);
}
@ -540,23 +473,3 @@ function formatFlowAmount(val: number): string {
if (absVal >= 10000) return (val / 10000).toFixed(1) + "亿";
return val.toFixed(0) + "万";
}
function markdownToHtml(md: string): string {
return md
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/^- (.+)$/gm, "<li>$1</li>")
.replace(/(<li>.*<\/li>\n?)+/g, (m) => `<ul>${m}</ul>`)
.replace(/\n{2,}/g, "</p><p>")
.replace(/^(?!<[hulo])/gm, "<p>")
.replace(/(?<![>])$/gm, "</p>")
.replace(/<p><\/p>/g, "")
.replace(/<p>(<h[23]>)/g, "$1")
.replace(/(<\/h[23]>)<\/p>/g, "$1")
.replace(/<p>(<ul>)/g, "$1")
.replace(/(<\/ul>)<\/p>/g, "$1");
}