This commit is contained in:
aaron 2026-04-16 14:16:02 +08:00
parent 5075fcc588
commit bb402cab00
48 changed files with 1018 additions and 691 deletions

View File

@ -225,15 +225,16 @@ def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
if not sectors: if not sectors:
return [] return []
# ── 板块阶段判定 ── # ── 板块阶段判定(结合连续天数与累计涨幅)──
for s in sectors: for s in sectors:
if s.days_continuous <= 2: cumulative_pct = round(sum(s.pct_trend), 2)
if s.days_continuous <= 2 or (s.days_continuous <= 3 and cumulative_pct < 5):
s.stage = "early" s.stage = "early"
elif s.days_continuous == 3: elif s.days_continuous == 3 and cumulative_pct >= 5 or (s.days_continuous == 4 and cumulative_pct < 8):
s.stage = "mid" s.stage = "mid"
elif s.days_continuous == 4: elif (s.days_continuous == 4 and cumulative_pct >= 8) or (s.days_continuous == 5 and cumulative_pct < 10):
s.stage = "late" s.stage = "late"
else: elif (s.days_continuous >= 5 and cumulative_pct >= 10) or s.days_continuous >= 6:
s.stage = "end" s.stage = "end"
# ── 综合评分 ── # ── 综合评分 ──

View File

@ -254,20 +254,24 @@ def generate_signals(ts_code: str, name: str = "") -> TechnicalSignal:
# 计算分数 # 计算分数
score = 0 score = 0
signal_count = 0 signal_count = 0
signals = { signal_map = [
ma_bullish: 15, (ma_bullish, 15),
volume_breakout: 20, (volume_breakout, 20),
macd_golden: 15, (macd_golden, 15),
rsi_healthy: 10, (rsi_healthy, 10),
pullback_support: 15, (pullback_support, 15),
big_yang: 15, (big_yang, 15),
boll_support: 10, (boll_support, 10),
} ]
for is_true, points in signals.items(): for is_true, points in signal_map:
if is_true: if is_true:
score += points score += points
signal_count += 1 signal_count += 1
# 趋势评分(与推荐体系一致)
from app.engine.screener import _score_trend
trend_score = round(_score_trend(df), 1)
# 支撑压力位 # 支撑压力位
support, resist = _calc_support_resist(df) support, resist = _calc_support_resist(df)
last_close = float(df.iloc[-1]["close"]) last_close = float(df.iloc[-1]["close"])
@ -287,6 +291,7 @@ def generate_signals(ts_code: str, name: str = "") -> TechnicalSignal:
big_yang=big_yang, big_yang=big_yang,
boll_support=boll_support, boll_support=boll_support,
score=score, score=score,
trend_score=trend_score,
signal_count=signal_count, signal_count=signal_count,
rally_pct_5d=rally_5d, rally_pct_5d=rally_5d,
rally_pct_10d=rally_10d, rally_pct_10d=rally_10d,

View File

@ -1,4 +1,8 @@
"""涨跌停/异动监控 API""" """涨跌停/异动监控 API
盘后使用 Tushare 涨跌停列表和日级数据完整准确
盘中涨跌停仍用 Tushare异动股用腾讯实时行情量比/振幅/急涨急跌
"""
from fastapi import APIRouter from fastapi import APIRouter
@ -12,10 +16,12 @@ router = APIRouter(prefix="/api/monitor", tags=["monitor"])
async def get_limits(): async def get_limits():
"""获取涨跌停数据""" """获取涨跌停数据"""
trade_date = tushare_client.get_latest_trade_date() trade_date = tushare_client.get_latest_trade_date()
is_realtime = is_market_session()
limit_df = tushare_client.get_limit_list(trade_date) limit_df = tushare_client.get_limit_list(trade_date)
if limit_df.empty: if limit_df.empty:
return {"trade_date": trade_date, "limit_up": [], "limit_down": []} return {"trade_date": trade_date, "is_realtime": False, "limit_up": [], "limit_down": []}
# 拆分涨停和跌停 # 拆分涨停和跌停
up_df = limit_df[limit_df["limit"] == "U"].sort_values("pct_chg", ascending=False) up_df = limit_df[limit_df["limit"] == "U"].sort_values("pct_chg", ascending=False)
@ -40,7 +46,7 @@ async def get_limits():
return { return {
"trade_date": trade_date, "trade_date": trade_date,
"is_realtime": is_market_session(), "is_realtime": is_realtime,
"limit_up": _parse(up_df), "limit_up": _parse(up_df),
"limit_down": _parse(down_df), "limit_down": _parse(down_df),
} }
@ -48,13 +54,22 @@ async def get_limits():
@router.get("/unusual") @router.get("/unusual")
async def get_unusual(): async def get_unusual():
"""获取异动股(量比>3、振幅>8%、快速拉升)""" """获取异动股(量比>3、振幅>8%、快速拉升)
trade_date = tushare_client.get_latest_trade_date()
# 获取全市场日线 盘中时使用腾讯实时行情补充量比和涨跌幅
盘后使用 Tushare 日级数据
"""
trade_date = tushare_client.get_latest_trade_date()
is_realtime = is_market_session()
if is_realtime:
# 盘中:用腾讯实时行情扫描异动
return await _get_unusual_realtime(trade_date)
# 盘后:使用 Tushare 日级数据
daily = tushare_client.get_daily_all(trade_date) daily = tushare_client.get_daily_all(trade_date)
if daily.empty: if daily.empty:
return {"trade_date": trade_date, "stocks": []} return {"trade_date": trade_date, "is_realtime": False, "stocks": []}
basic = tushare_client.get_daily_basic(trade_date) basic = tushare_client.get_daily_basic(trade_date)
if not basic.empty: if not basic.empty:
@ -66,7 +81,6 @@ async def get_unusual():
for _, r in stock_basic.iterrows(): for _, r in stock_basic.iterrows():
name_map[r["ts_code"]] = r["name"] name_map[r["ts_code"]] = r["name"]
# 筛选异动条件
unusual = [] unusual = []
for _, r in daily.iterrows(): for _, r in daily.iterrows():
ts = r.get("ts_code", "") ts = r.get("ts_code", "")
@ -103,4 +117,55 @@ async def get_unusual():
}) })
unusual.sort(key=lambda x: abs(x["pct_chg"]), reverse=True) unusual.sort(key=lambda x: abs(x["pct_chg"]), reverse=True)
return {"trade_date": trade_date, "stocks": unusual[:50]} return {"trade_date": trade_date, "is_realtime": False, "stocks": unusual[:50]}
async def _get_unusual_realtime(trade_date: str) -> dict:
"""盘中:用腾讯实时行情扫描异动"""
from app.data.tencent_client import get_realtime_quotes_batch
stock_basic = tushare_client.get_stock_basic()
if stock_basic.empty:
return {"trade_date": trade_date, "is_realtime": True, "stocks": []}
# 只扫描主板(非 ST
valid = stock_basic[
~stock_basic["name"].str.contains("ST", na=False)
]
codes = valid["ts_code"].tolist()
# 分批获取实时行情
unusual = []
batch_size = 200
for i in range(0, len(codes), batch_size):
batch = codes[i:i + batch_size]
quotes = await get_realtime_quotes_batch(batch)
for ts_code, q in quotes.items():
if not q.price or q.price <= 0:
continue
tags = []
if q.volume_ratio and q.volume_ratio > 3:
tags.append("巨量")
if q.amplitude and q.amplitude > 8:
tags.append("高振幅")
if q.pct_chg > 7:
tags.append("急涨")
elif q.pct_chg < -7:
tags.append("急跌")
if tags:
unusual.append({
"ts_code": ts_code,
"name": q.name or ts_code,
"close": q.price,
"pct_chg": q.pct_chg,
"amplitude": round(q.amplitude, 2) if q.amplitude else 0,
"volume_ratio": round(q.volume_ratio, 2) if q.volume_ratio else 0,
"turnover_rate": round(q.turnover_rate, 2) if q.turnover_rate else 0,
"tags": tags,
})
unusual.sort(key=lambda x: abs(x["pct_chg"]), reverse=True)
return {"trade_date": trade_date, "is_realtime": True, "stocks": unusual[:50]}

View File

@ -43,6 +43,8 @@ async def get_latest():
"sector_score": r.sector_score, "sector_score": r.sector_score,
"capital_score": r.capital_score, "capital_score": r.capital_score,
"technical_score": r.technical_score, "technical_score": r.technical_score,
"supply_demand_score": r.supply_demand_score,
"price_action_score": r.price_action_score,
"position_score": r.position_score, "position_score": r.position_score,
"valuation_score": r.valuation_score, "valuation_score": r.valuation_score,
"entry_price": r.entry_price, "entry_price": r.entry_price,
@ -79,6 +81,20 @@ async def refresh(scan_session: str = "manual"):
} }
@router.post("/update-tracking")
async def update_tracking():
"""独立更新推荐跟踪数据(不触发新扫描,盘中可单独调用)"""
from app.engine.recommender import _update_tracking
await _update_tracking()
stats = await get_performance_stats()
return {
"status": "ok",
"tracked": stats.get("tracked", 0),
"win_rate": stats.get("win_rate", 0),
"avg_return": stats.get("avg_return", 0),
}
@router.get("/status") @router.get("/status")
async def get_scan_status(): async def get_scan_status():
"""获取当前扫描状态信息""" """获取当前扫描状态信息"""

View File

@ -7,6 +7,7 @@ from fastapi import APIRouter
from app.config import is_market_session from app.config import is_market_session
from app.data.tushare_client import tushare_client from app.data.tushare_client import tushare_client
from app.data.tencent_client import get_realtime_quotes_batch from app.data.tencent_client import get_realtime_quotes_batch
from app.data.cache import cache
from app.engine.recommender import get_latest_sectors from app.engine.recommender import get_latest_sectors
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -122,6 +123,12 @@ async def get_hot_sectors(limit: int = 10):
@router.get("/rotation") @router.get("/rotation")
async def get_sector_rotation(days: int = 5): async def get_sector_rotation(days: int = 5):
"""获取近N日板块轮动数据用于热力图""" """获取近N日板块轮动数据用于热力图"""
# 检查缓存
cache_key = f"sector_rotation:{days}"
cached = cache.get(cache_key)
if cached is not None:
return cached
trade_date = tushare_client.get_latest_trade_date() trade_date = tushare_client.get_latest_trade_date()
# 获取交易日历 # 获取交易日历
@ -190,4 +197,9 @@ async def get_sector_rotation(days: int = 5):
reverse=True, reverse=True,
)[:20] )[:20]
return {"trade_date": trade_date, "dates": recent_dates, "sectors": sorted_sectors} result = {"trade_date": trade_date, "dates": recent_dates, "sectors": sorted_sectors}
# 写入缓存TTL 300秒5分钟
cache.set(cache_key, result, ttl=300)
return result

View File

@ -1,12 +1,20 @@
"""个股分析 API""" """个股分析 API"""
import json
import logging
from datetime import datetime, timedelta
from fastapi import APIRouter from fastapi import APIRouter
from starlette.responses import StreamingResponse
from app.data.tushare_client import tushare_client from app.data.tushare_client import tushare_client
from app.data import tencent_client from app.data import tencent_client
from app.analysis.technical import add_all_indicators from app.analysis.technical import add_all_indicators
from app.analysis.signals import generate_signals from app.analysis.signals import generate_signals
from app.db.database import get_db
from app.db import tables
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/stocks", tags=["stocks"]) router = APIRouter(prefix="/api/stocks", tags=["stocks"])
@ -91,35 +99,115 @@ async def search_stock(keyword: str):
return matches[["ts_code", "name", "industry"]].to_dict(orient="records") return matches[["ts_code", "name", "industry"]].to_dict(orient="records")
@router.get("/{ts_code}/diagnose/history")
async def get_diagnose_history(ts_code: str):
"""获取个股最近5次诊断历史"""
try:
from sqlalchemy import text
async with get_db() as db:
result = await db.execute(
text(
"SELECT id, ts_code, name, diagnosis, created_at "
"FROM stock_diagnoses "
"WHERE ts_code = :code "
"ORDER BY created_at DESC LIMIT 5"
),
{"code": ts_code},
)
rows = result.fetchall()
history = []
for row in rows:
r = row._mapping
history.append({
"id": r["id"],
"ts_code": r["ts_code"],
"name": r["name"],
"diagnosis": r["diagnosis"],
"created_at": str(r["created_at"]),
})
return history
except Exception as e:
logger.error(f"获取诊断历史失败: {e}")
return []
@router.post("/{ts_code}/diagnose") @router.post("/{ts_code}/diagnose")
async def diagnose_stock(ts_code: str): async def diagnose_stock(ts_code: str):
"""AI 诊断个股""" """AI 诊断个股SSE 流式返回)"""
from app.config import settings from app.config import settings
if not settings.deepseek_api_key: if not settings.deepseek_api_key:
return {"status": "error", "message": "未配置 LLM API Key"} return {"status": "error", "message": "未配置 LLM API Key"}
from app.llm.client import get_client from app.llm.client import get_client
from sqlalchemy import text
# 收集数据 # ── 检查是否有最近30分钟内的诊断记录若有则直接返回 ──
try:
async with get_db() as db:
result = await db.execute(
text(
"SELECT id, ts_code, name, diagnosis, created_at "
"FROM stock_diagnoses "
"WHERE ts_code = :code "
"AND created_at >= datetime('now', '-30 minutes', 'localtime') "
"ORDER BY created_at DESC LIMIT 1"
),
{"code": ts_code},
)
recent_row = result.fetchone()
if recent_row:
r = recent_row._mapping
# 直接返回缓存结果
async def _cached_stream():
yield f"data: {json.dumps({'cached': True, 'diagnosis': r['diagnosis']}, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'done': True, 'ts_code': ts_code})}\n\n"
return StreamingResponse(_cached_stream(), media_type="text/event-stream")
except Exception as e:
logger.warning(f"检查诊断缓存失败: {e}")
# ── 收集数据 ──
quote = await tencent_client.get_realtime_quote(ts_code) quote = await tencent_client.get_realtime_quote(ts_code)
signals = generate_signals(ts_code) signals = generate_signals(ts_code)
df_daily = tushare_client.get_stock_daily(ts_code, days=30) df_daily = tushare_client.get_stock_daily(ts_code, days=120)
df_flow = tushare_client.get_stock_moneyflow(ts_code, days=10) df_flow = tushare_client.get_stock_moneyflow(ts_code, days=10)
# ── 数据新鲜度检查 ──
freshness_note = ""
data_stale = False
current_price_source = ""
if not df_daily.empty:
df_daily = df_daily.sort_values("trade_date")
latest_kline_date = str(df_daily.iloc[-1]["trade_date"])
# 检查 K 线数据是否超过 10 天未更新
cutoff_date = (datetime.now() - timedelta(days=10)).strftime("%Y%m%d")
if latest_kline_date < cutoff_date:
logger.warning(f"K线数据过时 {ts_code}: 最新={latest_kline_date}, 10天前阈值={cutoff_date}")
data_stale = True
# 如果最新 K 线日期不是今天,添加新鲜度提示
today_str = datetime.now().strftime("%Y%m%d")
if latest_kline_date != today_str:
freshness_note = f"\n\n注意K线数据最新日期为{latest_kline_date},非当日数据,部分分析可能滞后。"
# 数据过时时,使用实时报价价格作为"当前价"替代
if data_stale and quote and quote.price > 0:
current_price_source = f"(实时报价价 {quote.price}K线收盘价 {df_daily.iloc[-1]['close']} 可能滞后)"
# 构建数据摘要 # 构建数据摘要
quote_str = "" quote_str = ""
if quote: if quote:
quote_str = ( quote_str = (
f"当前价: {quote.price}, 涨跌幅: {quote.pct_chg}%, " f"当前价: {quote.price}{current_price_source}, 涨跌幅: {quote.pct_chg}%, "
f"换手率: {quote.turnover_rate}%, 量比: {quote.volume_ratio}, " f"换手率: {quote.turnover_rate}%, 量比: {quote.volume_ratio}, "
f"PE: {quote.pe}, PB: {quote.pb}, " f"PE: {quote.pe}, PB: {quote.pb}, "
f"总市值: {quote.total_mv}亿, 流通市值: {quote.circ_mv}亿" f"总市值: {quote.total_mv}亿, 流通市值: {quote.circ_mv}亿"
) )
signal_str = ( signal_str = (
f"技术评分: {signals.score}/100基于7项技术信号触发计分触发少不代表一定差可能处于蓄势阶段, " f"推荐体系评分: 趋势评分={signals.trend_score}/100均线排列+高低点结构+MA20方向主评分10%权重), "
f"信号数: {signals.signal_count}/7, " f"辅助信号计数={signals.signal_count}/7(触发计分,仅供参考不参与主评分), "
f"均线多头: {signals.ma_bullish}, " f"均线多头: {signals.ma_bullish}, "
f"放量突破: {signals.volume_breakout}, " f"放量突破: {signals.volume_breakout}, "
f"MACD金叉: {signals.macd_golden}, " f"MACD金叉: {signals.macd_golden}, "
@ -142,7 +230,6 @@ async def diagnose_stock(ts_code: str):
trend_str = "" trend_str = ""
ma_info = "" ma_info = ""
if not df_daily.empty: if not df_daily.empty:
df_daily = df_daily.sort_values("trade_date")
latest = df_daily.iloc[-1] latest = df_daily.iloc[-1]
if len(df_daily) >= 5: if len(df_daily) >= 5:
pct_5d = (latest["close"] - df_daily.iloc[-5]["close"]) / df_daily.iloc[-5]["close"] * 100 pct_5d = (latest["close"] - df_daily.iloc[-5]["close"]) / df_daily.iloc[-5]["close"] * 100
@ -171,6 +258,7 @@ async def diagnose_stock(ts_code: str):
flow_str = "" flow_str = ""
if not df_flow.empty: if not df_flow.empty:
df_flow = df_flow.sort_values("trade_date") df_flow = df_flow.sort_values("trade_date")
latest_flow_date = str(df_flow.iloc[-1]["trade_date"])
recent_3 = df_flow.tail(3) recent_3 = df_flow.tail(3)
total_main = 0 total_main = 0
for _, r in recent_3.iterrows(): for _, r in recent_3.iterrows():
@ -180,15 +268,78 @@ async def diagnose_stock(ts_code: str):
) )
total_main += main_net total_main += main_net
flow_str = f"近3日主力净流入: {total_main:.0f}" flow_str = f"近3日主力净流入: {total_main:.0f}"
# 资金流向数据新鲜度标注
today_str = datetime.now().strftime("%Y%m%d")
if latest_flow_date != today_str:
flow_str += f"(数据截至{latest_flow_date},盘中可能滞后一日)"
# 基本信息 # 基本信息
basic_info = "" basic_info = ""
stock_name = ""
industry = ""
basic_df = tushare_client.get_stock_basic() basic_df = tushare_client.get_stock_basic()
if not basic_df.empty: if not basic_df.empty:
row = basic_df[basic_df["ts_code"] == ts_code] row = basic_df[basic_df["ts_code"] == ts_code]
if not row.empty: if not row.empty:
r = row.iloc[0] r = row.iloc[0]
basic_info = f"名称: {r['name']}, 行业: {r.get('industry', '未知')}" stock_name = r["name"]
industry = r.get("industry", "") or ""
basic_info = f"名称: {r['name']}, 行业: {industry}"
# 推荐体系评分(如果该股票在推荐列表中)
rec_score_str = ""
try:
async with get_db() as db:
rec_result = await db.execute(
text(
"SELECT score, supply_demand_score, price_action_score, "
"technical_score, position_score, sector, signal "
"FROM recommendations "
"WHERE ts_code = :code AND score >= 60 "
"ORDER BY created_at DESC LIMIT 1"
),
{"code": ts_code},
)
rec_row = rec_result.fetchone()
if rec_row:
rm = rec_row._mapping
rec_score_str = (
f"\n推荐体系评分: 综合={rm['score']}, "
f"供需={rm['supply_demand_score']}(50%权重), "
f"形态={rm['price_action_score']}(40%权重), "
f"趋势={rm['technical_score']}(10%权重), "
f"位置安全={rm['position_score']}, "
f"板块={rm['sector']}, "
f"信号={rm['signal']}"
)
except Exception:
pass
# 板块热度(如果有该行业板块数据)
sector_str = ""
if industry:
try:
async with get_db() as db:
sector_result = await db.execute(
text(
"SELECT sector_name, pct_change, heat_score, stage, "
"days_continuous, limit_up_count "
"FROM sector_heat "
"WHERE sector_name LIKE :industry "
"ORDER BY created_at DESC LIMIT 1"
),
{"industry": f"%{industry}%"},
)
s_row = sector_result.fetchone()
if s_row:
sm = s_row._mapping
sector_str = (
f"板块热度: {sm['sector_name']} 涨幅={sm['pct_change']}%, "
f"热度={sm['heat_score']}, 阶段={sm['stage']}, "
f"连续{sm['days_continuous']}天, 涨停数={sm['limit_up_count']}"
)
except Exception:
pass
user_msg = f"""请对以下A股进行全面诊断分析 user_msg = f"""请对以下A股进行全面诊断分析
@ -202,25 +353,34 @@ async def diagnose_stock(ts_code: str):
趋势: {trend_str} 趋势: {trend_str}
{ma_info} {ma_info}
资金面: {flow_str} 资金面: {flow_str}
{rec_score_str}
{sector_str}
重要提示技术评分基于7项信号触发计分分数低不代表股票差可能处于蓄势阶段位置安全评分高(>80)表示股价处于相对低位请综合技术评分和位置安全评分一起判断 重要提示
1. 趋势评分是推荐体系的技术面核心分数均线排列40+高低点结构35+MA20方向25=满分100辅助信号计数仅供参考不参与主评分
2. 位置安全评分高(>80)表示股价处于相对低位(<40)表示可能追高
3. 如果有推荐体系评分请作为主要分析依据趋势评分和信号计数从不同维度描述技术面状态
{freshness_note}
请从以下维度分析Markdown格式简洁专业 请从以下维度分析Markdown格式简洁专业
## 综合评级 ## 综合评级
给出1-5星评级和一句话总结综合技术面和位置安全评分 给出1-5星评级和一句话总结综合趋势评分位置安全和供需形态
## 技术面分析 ## 技术面分析
趋势方向均线关系支撑压力量价配合注意区分"技术信号未触发""技术面恶化" 趋势方向均线关系支撑压力量价配合优先参考趋势评分而非信号计数
## 资金面分析 ## 资金面分析
主力资金态度筹码集中度推测 主力资金态度板块联动效应
## 操作建议 ## 操作建议
适合什么类型的投资者入场时机风险提示""" 适合什么类型的投资者入场时机风险提示"""
# ── SSE 流式返回 ──
async def _stream_diagnosis():
full_content = ""
try: try:
client = get_client() client = get_client()
response = await client.chat.completions.create( stream = await client.chat.completions.create(
model=settings.deepseek_model, model=settings.deepseek_model,
messages=[ messages=[
{"role": "system", "content": "你是一位专业的A股分析师擅长技术面和资金面分析。回复使用Markdown格式简洁专业客观理性。"}, {"role": "system", "content": "你是一位专业的A股分析师擅长技术面和资金面分析。回复使用Markdown格式简洁专业客观理性。"},
@ -228,8 +388,38 @@ async def diagnose_stock(ts_code: str):
], ],
max_tokens=1500, max_tokens=1500,
temperature=0.5, temperature=0.5,
stream=True,
) )
content = response.choices[0].message.content.strip() async for chunk in stream:
return {"status": "ok", "ts_code": ts_code, "diagnosis": content} if chunk.choices and chunk.choices[0].delta:
token = chunk.choices[0].delta.content or ""
if token:
full_content += token
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
# 流式完成后,保存到数据库
full_content = full_content.strip()
if full_content:
try:
async with get_db() as db:
await db.execute(
tables.stock_diagnoses_table.insert().values(
ts_code=ts_code,
name=stock_name or ts_code,
diagnosis=full_content,
)
)
await db.commit()
logger.info(f"已保存诊断结果到数据库: {ts_code}")
except Exception as e: except Exception as e:
return {"status": "error", "message": str(e)} logger.error(f"保存诊断结果到数据库失败: {e}")
yield f"data: {json.dumps({'done': True, 'ts_code': ts_code}, ensure_ascii=False)}\n\n"
except Exception as e:
error_msg = str(e)
logger.error(f"诊断流式调用失败: {error_msg}")
yield f"data: {json.dumps({'error': error_msg}, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'done': True, 'ts_code': ts_code}, ensure_ascii=False)}\n\n"
return StreamingResponse(_stream_diagnosis(), media_type="text/event-stream")

View File

@ -94,7 +94,8 @@ class TechnicalSignal(BaseModel):
pullback_support: bool = False # 缩量回踩支撑 pullback_support: bool = False # 缩量回踩支撑
big_yang: bool = False # 底部放量长阳 big_yang: bool = False # 底部放量长阳
boll_support: bool = False # 布林带下轨支撑 boll_support: bool = False # 布林带下轨支撑
score: float = 0 # 技术面总分 score: float = 0 # 信号触发计数分
trend_score: float = 0 # 趋势评分(推荐体系用的技术面分数)
signal_count: int = 0 # 满足的信号数量 signal_count: int = 0 # 满足的信号数量
# 位置安全评估(防追高) # 位置安全评估(防追高)
@ -118,6 +119,8 @@ class Recommendation(BaseModel):
sector_score: float sector_score: float
capital_score: float capital_score: float
technical_score: float technical_score: float
supply_demand_score: float = 0 # 供需评分主评分50%权重)
price_action_score: float = 0 # 价格行为评分主评分40%权重)
position_score: float = 50 # 位置安全得分 position_score: float = 50 # 位置安全得分
valuation_score: float = 50 # 估值安全得分 valuation_score: float = 50 # 估值安全得分
signal: str # BUY / SELL / HOLD signal: str # BUY / SELL / HOLD

View File

@ -34,6 +34,13 @@ async def init_db():
"ALTER TABLE market_temperature ADD COLUMN max_streak INTEGER", "ALTER TABLE market_temperature ADD COLUMN max_streak INTEGER",
"ALTER TABLE market_temperature ADD COLUMN broken_rate REAL", "ALTER TABLE market_temperature ADD COLUMN broken_rate REAL",
"ALTER TABLE recommendations ADD COLUMN entry_signal_type TEXT DEFAULT 'none'", "ALTER TABLE recommendations ADD COLUMN entry_signal_type TEXT DEFAULT 'none'",
"ALTER TABLE sector_heat ADD COLUMN stage TEXT",
"ALTER TABLE sector_heat ADD COLUMN days_continuous INTEGER",
"ALTER TABLE sector_heat ADD COLUMN member_count INTEGER",
"ALTER TABLE sector_heat ADD COLUMN leading_stocks TEXT",
"ALTER TABLE sector_heat ADD COLUMN pct_trend TEXT",
"ALTER TABLE sector_heat ADD COLUMN turnover_avg REAL",
"ALTER TABLE sector_heat ADD COLUMN main_force_ratio REAL",
]: ]:
try: try:
await conn.execute( await conn.execute(

View File

@ -18,6 +18,8 @@ recommendations_table = Table(
Column("sector_score", Float), Column("sector_score", Float),
Column("capital_score", Float), Column("capital_score", Float),
Column("technical_score", Float), Column("technical_score", Float),
Column("supply_demand_score", Float, default=0),
Column("price_action_score", Float, default=0),
Column("position_score", Float), Column("position_score", Float),
Column("valuation_score", Float), Column("valuation_score", Float),
Column("signal", Text), Column("signal", Text),
@ -42,6 +44,13 @@ sector_heat_table = Table(
Column("capital_inflow", Float), Column("capital_inflow", Float),
Column("limit_up_count", Integer), Column("limit_up_count", Integer),
Column("heat_score", Float), Column("heat_score", Float),
Column("stage", Text),
Column("days_continuous", Integer),
Column("member_count", Integer),
Column("leading_stocks", Text), # JSON string
Column("pct_trend", Text), # JSON string
Column("turnover_avg", Float),
Column("main_force_ratio", Float),
Column("trade_date", Text, nullable=False), Column("trade_date", Text, nullable=False),
Column("created_at", DateTime, server_default=func.now()), Column("created_at", DateTime, server_default=func.now()),
) )
@ -91,3 +100,12 @@ daily_reviews_table = Table(
Column("content", Text, default=""), Column("content", Text, default=""),
Column("created_at", DateTime, server_default=func.now()), Column("created_at", DateTime, server_default=func.now()),
) )
stock_diagnoses_table = Table(
"stock_diagnoses", metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("ts_code", Text, nullable=False),
Column("name", Text, nullable=False),
Column("diagnosis", Text, nullable=False),
Column("created_at", DateTime, server_default=func.now()),
)

View File

@ -5,6 +5,7 @@
""" """
import logging import logging
import json
from datetime import datetime from datetime import datetime
from app.engine.screener import run_screening from app.engine.screener import run_screening
from app.data.models import Recommendation, MarketTemperature, SectorInfo from app.data.models import Recommendation, MarketTemperature, SectorInfo
@ -138,29 +139,31 @@ async def get_performance_stats() -> dict:
) )
tracked = result.scalar() or 0 tracked = result.scalar() or 0
# 盈利pct_from_entry > 0的推荐数 # 胜率基于最新跟踪日的最终 pct正值=盈利,负值=亏损)
result = await db.execute( result = await db.execute(
text( text(
"SELECT COUNT(*) FROM (" "SELECT COUNT(*) FROM ("
" SELECT t.recommendation_id, MAX(t.pct_from_entry) as max_pct " " SELECT t.recommendation_id, t.pct_from_entry as latest_pct "
" FROM recommendation_tracking t " " FROM recommendation_tracking t "
" GROUP BY t.recommendation_id" " INNER JOIN ("
") WHERE max_pct > 0" " SELECT recommendation_id, MAX(id) as max_id "
" FROM recommendation_tracking GROUP BY recommendation_id"
" ) latest ON t.id = latest.max_id"
") WHERE latest_pct > 0"
) )
) )
winning = result.scalar() or 0 winning = result.scalar() or 0
# 平均收益 # 平均收益(基于最新跟踪日的 pct
result = await db.execute( result = await db.execute(
text( text(
"SELECT AVG(latest_pct) FROM (" "SELECT AVG(latest_pct) FROM ("
" SELECT t.recommendation_id, t.pct_from_entry as latest_pct " " SELECT t.pct_from_entry as latest_pct "
" FROM recommendation_tracking t " " FROM recommendation_tracking t "
" INNER JOIN (" " INNER JOIN ("
" SELECT recommendation_id, MAX(track_date) as max_date " " SELECT recommendation_id, MAX(id) as max_id "
" FROM recommendation_tracking GROUP BY recommendation_id" " FROM recommendation_tracking GROUP BY recommendation_id"
" ) latest ON t.recommendation_id = latest.recommendation_id " " ) latest ON t.id = latest.max_id"
" AND t.track_date = latest.max_date"
")" ")"
) )
) )
@ -185,7 +188,7 @@ async def get_performance_stats() -> dict:
) )
hit_stop_count = result.scalar() or 0 hit_stop_count = result.scalar() or 0
# 最近5条有跟踪的推荐详情 # 最近跟踪的推荐详情
result = await db.execute( result = await db.execute(
text( text(
"SELECT r.ts_code, r.name, r.signal, r.entry_price, " "SELECT r.ts_code, r.name, r.signal, r.entry_price, "
@ -195,10 +198,9 @@ async def get_performance_stats() -> dict:
"FROM recommendations r " "FROM recommendations r "
"INNER JOIN recommendation_tracking t ON t.recommendation_id = r.id " "INNER JOIN recommendation_tracking t ON t.recommendation_id = r.id "
"INNER JOIN (" "INNER JOIN ("
" SELECT recommendation_id, MAX(track_date) as max_date " " SELECT recommendation_id, MAX(id) as max_id "
" FROM recommendation_tracking GROUP BY recommendation_id" " FROM recommendation_tracking GROUP BY recommendation_id"
") latest ON t.recommendation_id = latest.recommendation_id " ") latest ON t.id = latest.max_id "
" AND t.track_date = latest.max_date "
"ORDER BY r.created_at DESC LIMIT 20" "ORDER BY r.created_at DESC LIMIT 20"
) )
) )
@ -306,6 +308,8 @@ async def get_recommendation_history(days: int = 7) -> list[dict]:
"sector_score": r["sector_score"] or 0, "sector_score": r["sector_score"] or 0,
"capital_score": r["capital_score"] or 0, "capital_score": r["capital_score"] or 0,
"technical_score": r["technical_score"] or 0, "technical_score": r["technical_score"] or 0,
"supply_demand_score": r.get("supply_demand_score") or 0,
"price_action_score": r.get("price_action_score") or 0,
"position_score": r.get("position_score") or 50, "position_score": r.get("position_score") or 50,
"valuation_score": r.get("valuation_score") or 50, "valuation_score": r.get("valuation_score") or 50,
"entry_price": r["entry_price"], "entry_price": r["entry_price"],
@ -395,18 +399,24 @@ async def _save_to_db(result: dict):
capital_inflow=sector.capital_inflow, capital_inflow=sector.capital_inflow,
limit_up_count=sector.limit_up_count, limit_up_count=sector.limit_up_count,
heat_score=sector.heat_score, heat_score=sector.heat_score,
stage=sector.stage,
days_continuous=sector.days_continuous,
member_count=sector.member_count,
leading_stocks=json.dumps(sector.leading_stocks, ensure_ascii=False),
pct_trend=json.dumps(sector.pct_trend, ensure_ascii=False),
turnover_avg=sector.turnover_avg,
main_force_ratio=sector.main_force_ratio,
trade_date=trade_date_val, trade_date=trade_date_val,
) )
await db.execute(stmt) await db.execute(stmt)
# 保存推荐(先清除今日旧推荐,避免重复) # 保存推荐(按 ts_code 清除当日旧记录,避免同一天多次扫描产生重复)
today_str = datetime.now().strftime("%Y-%m-%d") 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", []): for rec in result.get("recommendations", []):
await db.execute(
text("DELETE FROM recommendations WHERE date(created_at) = :today AND ts_code = :code"),
{"today": today_str, "code": rec.ts_code},
)
stmt = tables.recommendations_table.insert().values( stmt = tables.recommendations_table.insert().values(
ts_code=rec.ts_code, ts_code=rec.ts_code,
name=rec.name, name=rec.name,
@ -416,6 +426,8 @@ async def _save_to_db(result: dict):
sector_score=rec.sector_score, sector_score=rec.sector_score,
capital_score=rec.capital_score, capital_score=rec.capital_score,
technical_score=rec.technical_score, technical_score=rec.technical_score,
supply_demand_score=rec.supply_demand_score,
price_action_score=rec.price_action_score,
position_score=rec.position_score, position_score=rec.position_score,
valuation_score=rec.valuation_score, valuation_score=rec.valuation_score,
signal=rec.signal, signal=rec.signal,
@ -464,10 +476,11 @@ async def _load_today_from_db() -> dict:
temperature=m["temperature"], temperature=m["temperature"],
) )
# 加载推荐(取最近一个有数据的日期,按 ts_code 去重 # 加载推荐(取最近一个有数据的日期,按 ts_code 去重,只取 >= 60 分
result = await db.execute( result = await db.execute(
text("SELECT * FROM recommendations " text("SELECT * FROM recommendations "
"WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) " "WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) "
"AND score >= 60 "
"AND id IN (SELECT MAX(id) FROM recommendations " "AND id IN (SELECT MAX(id) FROM recommendations "
" WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) " " WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) "
" GROUP BY ts_code) " " GROUP BY ts_code) "
@ -486,6 +499,8 @@ async def _load_today_from_db() -> dict:
sector_score=r["sector_score"] or 0, sector_score=r["sector_score"] or 0,
capital_score=r["capital_score"] or 0, capital_score=r["capital_score"] or 0,
technical_score=r["technical_score"] or 0, technical_score=r["technical_score"] or 0,
supply_demand_score=r.get("supply_demand_score") or 0,
price_action_score=r.get("price_action_score") or 0,
position_score=r.get("position_score") or 50, position_score=r.get("position_score") or 50,
valuation_score=r.get("valuation_score") or 50, valuation_score=r.get("valuation_score") or 50,
signal=r["signal"] or "HOLD", signal=r["signal"] or "HOLD",
@ -532,14 +547,23 @@ async def _load_sectors_from_db() -> list[SectorInfo]:
sectors = [] sectors = []
for row in rows: for row in rows:
r = row._mapping r = row._mapping
# Parse JSON fields with fallback
leading_stocks = json.loads(r.get("leading_stocks") or "[]")
pct_trend = json.loads(r.get("pct_trend") or "[]")
sectors.append(SectorInfo( sectors.append(SectorInfo(
sector_code=r["sector_code"], sector_code=r["sector_code"],
sector_name=r["sector_name"], sector_name=r["sector_name"],
pct_change=r["pct_change"] or 0, pct_change=r["pct_change"] or 0,
capital_inflow=r["capital_inflow"] or 0, capital_inflow=r["capital_inflow"] or 0,
limit_up_count=r["limit_up_count"] or 0, limit_up_count=r["limit_up_count"] or 0,
days_continuous=0, days_continuous=r.get("days_continuous") or 0,
heat_score=r["heat_score"] or 0, heat_score=r["heat_score"] or 0,
stage=r.get("stage") or "mid",
member_count=r.get("member_count") or 0,
leading_stocks=leading_stocks,
pct_trend=pct_trend,
turnover_avg=r.get("turnover_avg") or 0,
main_force_ratio=r.get("main_force_ratio") or 0,
)) ))
return sectors return sectors
except Exception as e: except Exception as e:

View File

@ -124,8 +124,8 @@ async def run_screening(trade_date: str = None) -> dict:
candidates, market_temp, hot_sectors, market_temp_score, intraday, candidates, market_temp, hot_sectors, market_temp_score, intraday,
) )
# 过滤低质量推荐 # 过滤低质量推荐低于60分不推荐
recommendations = [r for r in recommendations if r.score >= 40] recommendations = [r for r in recommendations if r.score >= 60]
logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===") logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===")
for r in recommendations[:5]: for r in recommendations[:5]:
@ -562,6 +562,8 @@ async def _build_recommendations(
sector_score=round(_get_sector_heat(sector, hot_sectors), 1), sector_score=round(_get_sector_heat(sector, hot_sectors), 1),
capital_score=round(_score_capital_simple(stock), 1), capital_score=round(_score_capital_simple(stock), 1),
technical_score=round(trend_score, 1), technical_score=round(trend_score, 1),
supply_demand_score=round(supply_demand_score, 1),
price_action_score=round(price_action_score, 1),
position_score=round(position_score, 1), position_score=round(position_score, 1),
valuation_score=round(valuation_score, 1), valuation_score=round(valuation_score, 1),
signal=signal, signal=signal,

View File

@ -1,135 +1,15 @@
"""推荐结果 LLM 增强 """推荐结果 LLM 增强
扫描完成后异步调用 LLM为每只推荐股票生成深度分析 现在统一使用 analysis_agent 模块进行深度分析
此文件保留为兼容入口内部直接调用 analysis_agent
""" """
import asyncio
import json
import logging import logging
from app.llm.client import chat_completion
from app.llm.prompts import ENHANCE_SYSTEM_PROMPT, ENHANCE_USER_TEMPLATE
from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def enhance_recommendations(result: dict) -> None: async def enhance_recommendations(result: dict) -> None:
"""对推荐结果进行 LLM 增强分析fire-and-forget""" """对推荐结果进行 LLM 增强分析(兼容入口,委托给 analysis_agent"""
if not settings.deepseek_api_key: from app.llm.analysis_agent import analyze_recommendations
return await analyze_recommendations(result)
recommendations = result.get("recommendations", [])
if not recommendations:
return
market_temp = result.get("market_temp")
hot_sectors = result.get("hot_sectors", [])
# 构建板块文本
sectors_text = "\n".join(
f"- {s.sector_name}: 涨幅{s.pct_change}%, 资金流入{s.capital_inflow}万, "
f"涨停{s.limit_up_count}家, 热度{s.heat_score}分, 阶段={s.stage}"
for s in hot_sectors[:5]
) if hot_sectors else "暂无板块数据"
# 温度等级
temp_val = market_temp.temperature if market_temp else 0
if temp_val >= 60:
temp_level = "积极"
elif temp_val >= 30:
temp_level = "谨慎"
else:
temp_level = "低迷"
enhanced_count = 0
for rec in recommendations:
try:
user_msg = ENHANCE_USER_TEMPLATE.format(
temperature=market_temp.temperature if market_temp else "N/A",
temp_level=temp_level,
up_count=market_temp.up_count if market_temp else 0,
down_count=market_temp.down_count if market_temp else 0,
limit_up_count=market_temp.limit_up_count if market_temp else 0,
max_streak=market_temp.max_streak if market_temp else 0,
broken_rate=market_temp.broken_rate if market_temp else 0,
sectors_text=sectors_text,
name=rec.name,
ts_code=rec.ts_code,
sector=rec.sector,
score=rec.score,
level=rec.level,
market_temp_score=rec.market_temp_score,
sector_score=rec.sector_score,
capital_score=rec.capital_score,
technical_score=rec.technical_score,
position_score=rec.position_score,
valuation_score=rec.valuation_score,
signal=rec.signal,
entry_price=rec.entry_price or "N/A",
target_price=rec.target_price or "N/A",
stop_loss=rec.stop_loss or "N/A",
reasons="".join(rec.reasons) if rec.reasons else "",
)
messages = [
{"role": "system", "content": ENHANCE_SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
]
resp = await chat_completion(messages)
if resp and resp.content:
rec.llm_analysis = resp.content.strip()
enhanced_count += 1
except asyncio.CancelledError:
logger.warning(f"LLM 增强 {rec.ts_code} 被取消")
break
except Exception as e:
logger.error(f"LLM 增强 {rec.ts_code} 失败: {e}")
if enhanced_count > 0:
# 更新数据库
await _save_llm_analysis_to_db(recommendations)
# 通过 WebSocket 通知前端
await _broadcast_llm_ready(recommendations)
logger.info(f"LLM 增强完成: {enhanced_count}/{len(recommendations)}")
async def _save_llm_analysis_to_db(recommendations: list) -> None:
"""将 LLM 分析结果更新到数据库"""
try:
from app.db.database import get_db
from sqlalchemy import text
async with get_db() as db:
for rec in recommendations:
if not rec.llm_analysis:
continue
await db.execute(
text(
"UPDATE recommendations SET llm_analysis = :analysis "
"WHERE ts_code = :code AND date(created_at) = date('now', 'localtime') "
"AND scan_session = :session"
),
{
"analysis": rec.llm_analysis,
"code": rec.ts_code,
"session": rec.scan_session,
},
)
await db.commit()
except Exception as e:
logger.error(f"保存 LLM 分析到数据库失败: {e}")
async def _broadcast_llm_ready(recommendations: list) -> None:
"""通过 WebSocket 广播 LLM 分析完成事件"""
try:
from app.api.websocket import broadcast_update
await broadcast_update({
"type": "llm_analysis_ready",
"count": len([r for r in recommendations if r.llm_analysis]),
})
except Exception as e:
logger.error(f"广播 LLM 分析完成失败: {e}")

Binary file not shown.

View File

@ -10,31 +10,6 @@
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/css/app/layout.css", "static/css/app/layout.css",
"static/chunks/app/layout.js" "static/chunks/app/layout.js"
],
"/stock/[code]/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/stock/[code]/page.js"
],
"/monitor/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/monitor/page.js"
],
"/recommendations/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/recommendations/page.js"
],
"/sectors/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.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,9 +2,7 @@
"polyfillFiles": [ "polyfillFiles": [
"static/chunks/polyfills.js" "static/chunks/polyfills.js"
], ],
"devFiles": [ "devFiles": [],
"static/chunks/react-refresh.js"
],
"ampDevFiles": [], "ampDevFiles": [],
"lowPriorityFiles": [ "lowPriorityFiles": [
"static/development/_buildManifest.js", "static/development/_buildManifest.js",
@ -15,16 +13,7 @@
"static/chunks/main-app.js" "static/chunks/main-app.js"
], ],
"pages": { "pages": {
"/_app": [ "/_app": []
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_app.js"
],
"/_error": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_error.js"
]
}, },
"ampFirstPages": [] "ampFirstPages": []
} }

File diff suppressed because one or more lines are too long

View File

@ -1,20 +1 @@
{ {}
"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,8 +1,3 @@
{ {
"/page": "app/page.js", "/page": "app/page.js"
"/stock/[code]/page": "app/stock/[code]/page.js",
"/monitor/page": "app/monitor/page.js",
"/recommendations/page": "app/recommendations/page.js",
"/sectors/page": "app/sectors/page.js",
"/diagnose/page": "app/diagnose/page.js"
} }

View File

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

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\"]},\"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\"]}}" 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": {}, "node": {},
"edge": {}, "edge": {},
"encryptionKey": "ivfiGgdCRmC7fJWUxiqW3o7IY5TIF27ivPa+HF5AgdE=" "encryptionKey": "s33pKc3VjlvggFQFOCpPXrHp6MilpQP7rFdwHOWbtO8="
} }

View File

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

File diff suppressed because one or more lines are too long

View File

@ -3,6 +3,7 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { streamChat, type ChatMessage } from "@/lib/api"; import { streamChat, type ChatMessage } from "@/lib/api";
import { formatMarkdown } from "@/lib/markdown";
interface DisplayMessage { interface DisplayMessage {
role: "user" | "assistant"; role: "user" | "assistant";
@ -216,18 +217,3 @@ export default function ChatPage() {
); );
} }
/** Simple markdown to HTML (bold, lists, newlines) */
function formatMarkdown(text: string): string {
let html = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/^\s*[-*]\s+(.+)/gm, "<li>$1</li>")
.replace(/\n/g, "<br>");
// Wrap consecutive <li> items in <ul>
html = html.replace(/(<li>.*?<\/li>(<br>)?)+/g, (match) => {
return "<ul>" + match.replace(/<br>/g, "") + "</ul>";
});
return html;
}

View File

@ -2,7 +2,10 @@
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { fetchAPI, postAPI, type DiagnosisResult } from "@/lib/api"; import { useSearchParams } from "next/navigation";
import { fetchAPI, type DiagnosisResult } from "@/lib/api";
import { markdownToHtml } from "@/lib/markdown";
import { ErrorBoundary } from "@/components/error-boundary";
interface SearchResult { interface SearchResult {
ts_code: string; ts_code: string;
@ -12,17 +15,20 @@ interface SearchResult {
export default function DiagnosePage() { export default function DiagnosePage() {
const { theme } = useTheme(); const { theme } = useTheme();
const searchParams = useSearchParams();
const codeParam = searchParams.get("code");
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [searchResults, setSearchResults] = useState<SearchResult[]>([]); const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [showSearch, setShowSearch] = useState(false); const [showSearch, setShowSearch] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [streamingContent, setStreamingContent] = useState("");
const [result, setResult] = useState<DiagnosisResult | null>(null); const [result, setResult] = useState<DiagnosisResult | null>(null);
const [cachedResult, setCachedResult] = useState<string | null>(null);
const [history, setHistory] = useState<{ ts_code: string; name: string }[]>([]); const [history, setHistory] = useState<{ ts_code: string; name: string }[]>([]);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout>>(); const searchTimer = useRef<ReturnType<typeof setTimeout>>();
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
// Close search dropdown on outside click
useEffect(() => { useEffect(() => {
function handleClick(e: MouseEvent) { function handleClick(e: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
@ -33,6 +39,12 @@ export default function DiagnosePage() {
return () => document.removeEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick);
}, []); }, []);
useEffect(() => {
if (!codeParam) return;
setInput(codeParam);
runDiagnosis(codeParam);
}, [codeParam]);
const searchStock = useCallback(async (keyword: string) => { const searchStock = useCallback(async (keyword: string) => {
if (!keyword.trim() || keyword.length < 1) { if (!keyword.trim() || keyword.length < 1) {
setSearchResults([]); setSearchResults([]);
@ -63,12 +75,10 @@ export default function DiagnosePage() {
const runDiagnosis = async (tsCode?: string) => { const runDiagnosis = async (tsCode?: string) => {
let code = tsCode; let code = tsCode;
if (!code) { if (!code) {
// Extract ts_code from input like "京投发展 (600683.SH)"
const match = input.match(/\((\d{6}\.[A-Z]{2})\)/); const match = input.match(/\((\d{6}\.[A-Z]{2})\)/);
if (match) { if (match) {
code = match[1]; code = match[1];
} else if (/^\d{6}$/.test(input.trim())) { } else if (/^\d{6}$/.test(input.trim())) {
// Try common suffixes
code = `${input.trim()}.SH`; code = `${input.trim()}.SH`;
} else if (/^\d{6}\.[A-Z]{2}$/.test(input.trim())) { } else if (/^\d{6}\.[A-Z]{2}$/.test(input.trim())) {
code = input.trim(); code = input.trim();
@ -78,16 +88,65 @@ export default function DiagnosePage() {
if (!code) return; if (!code) return;
setLoading(true); setLoading(true);
setStreamingContent("");
setResult(null); setResult(null);
setCachedResult(null);
try { try {
const res = await postAPI<DiagnosisResult>(`/api/stocks/${code}/diagnose`); const token = localStorage.getItem("auth_token");
setResult(res); const headers: Record<string, string> = {};
if (res.status === "ok" && res.ts_code) { if (token) headers["Authorization"] = `Bearer ${token}`;
const name = input.split(" (")[0] || res.ts_code;
setHistory((prev) => { const res = await fetch(`/api/stocks/${code}/diagnose`, {
const filtered = prev.filter((h) => h.ts_code !== res.ts_code); method: "POST",
return [{ ts_code: res.ts_code!, name }, ...filtered].slice(0, 10); headers,
}); });
if (!res.ok) throw new Error(`API error: ${res.status}`);
if (!res.body) throw new Error("No response body");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let fullContent = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6).trim();
try {
const parsed = JSON.parse(data);
if (parsed.cached && parsed.diagnosis) {
setCachedResult(parsed.diagnosis);
fullContent = parsed.diagnosis;
} else if (parsed.token) {
fullContent += parsed.token;
setStreamingContent(fullContent);
} else if (parsed.error) {
setResult({ status: "error", message: parsed.error });
} else if (parsed.done) {
if (fullContent) {
setResult({ status: "ok", ts_code: parsed.ts_code || code, diagnosis: fullContent });
const name = input.split(" (")[0] || parsed.ts_code || code;
setHistory((prev) => {
const filtered = prev.filter((h) => h.ts_code !== (parsed.ts_code || code));
return [{ ts_code: parsed.ts_code || code, name }, ...filtered].slice(0, 10);
});
}
}
} catch {
// ignore malformed lines
}
}
}
} }
} catch (e) { } catch (e) {
setResult({ status: "error", message: "诊断失败,请检查股票代码后重试" }); setResult({ status: "error", message: "诊断失败,请检查股票代码后重试" });
@ -103,7 +162,10 @@ export default function DiagnosePage() {
} }
}; };
const displayContent = cachedResult || result?.diagnosis || streamingContent;
return ( return (
<ErrorBoundary>
<div className="max-w-3xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10"> <div className="max-w-3xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
{/* Header */} {/* Header */}
<div className="mb-6 animate-fade-in-up"> <div className="mb-6 animate-fade-in-up">
@ -178,7 +240,7 @@ export default function DiagnosePage() {
</div> </div>
{/* History */} {/* History */}
{history.length > 0 && !result && ( {history.length > 0 && !displayContent && (
<div className="mb-6 animate-fade-in-up"> <div className="mb-6 animate-fade-in-up">
<div className="text-[10px] text-text-muted/50 mb-2 uppercase tracking-wider"></div> <div className="text-[10px] text-text-muted/50 mb-2 uppercase tracking-wider"></div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@ -199,8 +261,8 @@ export default function DiagnosePage() {
</div> </div>
)} )}
{/* Loading State */} {/* Streaming / Loading State */}
{loading && ( {loading && !displayContent && (
<div className="glass-card-static p-10 text-center animate-fade-in-up"> <div className="glass-card-static p-10 text-center animate-fade-in-up">
<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="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-sm text-text-secondary mb-1">...</div> <div className="text-sm text-text-secondary mb-1">...</div>
@ -208,20 +270,32 @@ export default function DiagnosePage() {
</div> </div>
)} )}
{/* Result */} {/* Streaming content */}
{result && !loading && ( {loading && displayContent && (
<div className="glass-card-static p-5 animate-fade-in-up">
<div className="flex items-center gap-2 mb-3">
<span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
<span className="text-xs text-text-muted">AI ...</span>
</div>
<div className="text-sm text-text-secondary leading-relaxed whitespace-pre-line">
{displayContent}
</div>
</div>
)}
{/* Final Result */}
{!loading && displayContent && (
<div className="animate-fade-in-up"> <div className="animate-fade-in-up">
{result.status === "ok" && result.diagnosis ? ( <div className="glass-card-static p-5">
<div className="glass-card-static p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-primary">{result.ts_code}</span> <span className="text-sm font-semibold text-text-primary">{result?.ts_code || codeParam}</span>
<span className="text-xs px-2 py-0.5 rounded-md bg-emerald-500/10 text-emerald-400 border border-emerald-500/15"> <span className="text-[10px] px-1.5 py-0.5 rounded-md bg-emerald-500/10 text-emerald-400 border border-emerald-500/15">
{cachedResult ? "缓存" : "分析完成"}
</span> </span>
</div> </div>
<button <button
onClick={() => runDiagnosis(result.ts_code)} onClick={() => runDiagnosis(result?.ts_code || codeParam || undefined)}
className="text-xs text-text-muted hover:text-amber-400 transition-colors flex items-center gap-1" className="text-xs text-text-muted hover:text-amber-400 transition-colors flex items-center gap-1"
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@ -233,20 +307,22 @@ export default function DiagnosePage() {
</div> </div>
<div <div
className={`text-sm text-text-secondary leading-relaxed prose prose-sm max-w-none [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-amber-400 [&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:first:mt-0 [&_h3]:text-xs [&_h3]:font-semibold [&_h3]:text-text-primary [&_h3]:mt-3 [&_h3]:mb-1.5 [&_p]:text-text-secondary [&_p]:mb-2.5 [&_p]:leading-relaxed [&_ul]:text-text-secondary [&_ul]:mb-2.5 [&_li]:mb-1 [&_strong]:text-text-primary ${theme !== "light" ? "prose-invert" : ""}`} className={`text-sm text-text-secondary leading-relaxed prose prose-sm max-w-none [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-amber-400 [&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:first:mt-0 [&_h3]:text-xs [&_h3]:font-semibold [&_h3]:text-text-primary [&_h3]:mt-3 [&_h3]:mb-1.5 [&_p]:text-text-secondary [&_p]:mb-2.5 [&_p]:leading-relaxed [&_ul]:text-text-secondary [&_ul]:mb-2.5 [&_li]:mb-1 [&_strong]:text-text-primary ${theme !== "light" ? "prose-invert" : ""}`}
dangerouslySetInnerHTML={{ __html: markdownToHtml(result.diagnosis) }} dangerouslySetInnerHTML={{ __html: markdownToHtml(displayContent) }}
/> />
</div> </div>
) : ( </div>
<div className="glass-card-static p-8 text-center"> )}
{/* Error */}
{result?.status === "error" && !loading && !displayContent && (
<div className="glass-card-static p-8 text-center animate-fade-in-up">
<div className="text-sm text-red-400 mb-2"></div> <div className="text-sm text-red-400 mb-2"></div>
<div className="text-xs text-text-muted">{result.message || "未知错误"}</div> <div className="text-xs text-text-muted">{result.message || "未知错误"}</div>
</div> </div>
)} )}
</div>
)}
{/* Empty state */} {/* Empty state */}
{!result && !loading && history.length === 0 && ( {!result && !loading && !displayContent && history.length === 0 && (
<div className="glass-card-static p-10 text-center animate-fade-in-up"> <div className="glass-card-static p-10 text-center animate-fade-in-up">
<div className="w-12 h-12 rounded-2xl bg-surface-2 flex items-center justify-center mx-auto mb-4"> <div className="w-12 h-12 rounded-2xl bg-surface-2 flex items-center justify-center mx-auto mb-4">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-text-muted/40"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-text-muted/40">
@ -275,25 +351,6 @@ export default function DiagnosePage() {
</div> </div>
)} )}
</div> </div>
</ErrorBoundary>
); );
} }
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");
}

View File

@ -3,6 +3,7 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { fetchAPI } from "@/lib/api"; import { fetchAPI } from "@/lib/api";
import type { LimitsData, UnusualStock } from "@/lib/api"; import type { LimitsData, UnusualStock } from "@/lib/api";
import { ErrorBoundary } from "@/components/error-boundary";
export default function MonitorPage() { export default function MonitorPage() {
const [tab, setTab] = useState<"limits" | "unusual">("limits"); const [tab, setTab] = useState<"limits" | "unusual">("limits");
@ -33,7 +34,25 @@ export default function MonitorPage() {
loadData(); loadData();
}, [loadData]); }, [loadData]);
const isInTradingHours = useCallback(() => {
const now = new Date();
const hour = now.getHours();
const minute = now.getMinutes();
const day = now.getDay(); // 0=Sunday
if (day === 0 || day === 6) return false;
if ((hour === 9 && minute >= 30) || hour === 10 || (hour === 11 && minute <= 30)) return true;
if (hour === 13 || hour === 14 || (hour === 15 && minute === 0)) return true;
return false;
}, []);
useEffect(() => {
if (!isInTradingHours()) return;
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
}, [loadData, isInTradingHours]);
return ( return (
<ErrorBoundary>
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10"> <div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-5 animate-fade-in-up"> <div className="flex items-center justify-between mb-5 animate-fade-in-up">
@ -82,6 +101,7 @@ export default function MonitorPage() {
<UnusualView stocks={unusualStocks} /> <UnusualView stocks={unusualStocks} />
)} )}
</div> </div>
</ErrorBoundary>
); );
} }

View File

@ -9,6 +9,7 @@ import SectorHeatmap from "@/components/sector-heatmap";
import { useWebSocket } from "@/hooks/use-websocket"; import { useWebSocket } from "@/hooks/use-websocket";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { markdownToHtml } from "@/lib/markdown";
interface ScanStatus { interface ScanStatus {
is_trading: boolean; is_trading: boolean;
@ -61,7 +62,8 @@ export default function DashboardPage() {
useWebSocket( useWebSocket(
useCallback(() => { useCallback(() => {
loadData(); loadData();
}, [loadData]) }, [loadData]),
["llm_analysis_ready", "sector_scan_ready", "scan_complete"]
); );
const handleRefresh = async () => { const handleRefresh = async () => {
@ -253,22 +255,3 @@ export default function DashboardPage() {
); );
} }
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");
}

View File

@ -103,7 +103,9 @@ export default function RecommendationsPage() {
return recs.filter((r) => r.level === filter); return recs.filter((r) => r.level === filter);
}; };
// 总数统计 // 今日推荐数
const todayCount = dayGroups.length > 0 ? applyFilter(dayGroups[0].recommendations).length : 0;
// 累计总数
const totalCount = dayGroups.reduce((sum, g) => sum + applyFilter(g.recommendations).length, 0); const totalCount = dayGroups.reduce((sum, g) => sum + applyFilter(g.recommendations).length, 0);
return ( return (
@ -113,7 +115,8 @@ export default function RecommendationsPage() {
<div> <div>
<h1 className="text-base sm:text-lg font-bold tracking-tight"></h1> <h1 className="text-base sm:text-lg font-bold tracking-tight"></h1>
<p className="text-xs text-text-muted mt-0.5"> <p className="text-xs text-text-muted mt-0.5">
<span className="font-mono tabular-nums">{totalCount}</span> · <span className="font-mono tabular-nums text-amber-400">{todayCount}</span> ·
<span className="font-mono tabular-nums ml-1">{totalCount}</span> ·
<span className="font-mono tabular-nums ml-1">{dayGroups.length}</span> <span className="font-mono tabular-nums ml-1">{dayGroups.length}</span>
</p> </p>
</div> </div>

View File

@ -5,6 +5,7 @@ import { fetchAPI } from "@/lib/api";
import type { SectorData, LeadingStock, SectorRotationData } from "@/lib/api"; import type { SectorData, LeadingStock, SectorRotationData } from "@/lib/api";
import { formatNumber } from "@/lib/utils"; import { formatNumber } from "@/lib/utils";
import { useWebSocket } from "@/hooks/use-websocket"; import { useWebSocket } from "@/hooks/use-websocket";
import { ErrorBoundary } from "@/components/error-boundary";
function getStageInfo(stage: string) { function getStageInfo(stage: string) {
switch (stage) { switch (stage) {
@ -224,6 +225,7 @@ export default function SectorsPage() {
}, [showRotation, rotationData, loadRotation]); }, [showRotation, rotationData, loadRotation]);
return ( return (
<ErrorBoundary>
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10"> <div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
<div className="flex items-center justify-between mb-5 animate-fade-in-up"> <div className="flex items-center justify-between mb-5 animate-fade-in-up">
<div> <div>
@ -272,6 +274,7 @@ export default function SectorsPage() {
</div> </div>
)} )}
</div> </div>
</ErrorBoundary>
); );
} }

View File

@ -3,14 +3,17 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { fetchAPI } from "@/lib/api"; import { fetchAPI } from "@/lib/api";
import type { RecommendationData, DayGroup } from "@/lib/api";
import { getScoreColor } from "@/lib/utils"; import { getScoreColor } from "@/lib/utils";
import KlineChart from "@/components/kline-chart"; import KlineChart from "@/components/kline-chart";
import CapitalFlowChart from "@/components/capital-flow"; import CapitalFlowChart from "@/components/capital-flow";
import { ErrorBoundary } from "@/components/error-boundary";
interface StockSignals { interface StockSignals {
ts_code: string; ts_code: string;
name: string; name: string;
score: number; score: number;
trend_score: number;
signal_count: number; signal_count: number;
ma_bullish: boolean; ma_bullish: boolean;
volume_breakout: boolean; volume_breakout: boolean;
@ -28,6 +31,14 @@ interface StockSignals {
position_score: number; position_score: number;
} }
interface RecScore {
supply_demand_score: number;
price_action_score: number;
technical_score: number;
position_score: number;
score: number;
}
interface QuoteData { interface QuoteData {
ts_code: string; ts_code: string;
name: string; name: string;
@ -66,6 +77,7 @@ export default function StockDetailPage() {
const [quote, setQuote] = useState<QuoteData | null>(null); const [quote, setQuote] = useState<QuoteData | null>(null);
const [signals, setSignals] = useState<StockSignals | null>(null); const [signals, setSignals] = useState<StockSignals | null>(null);
const [recScore, setRecScore] = useState<RecScore | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const [kline, setKline] = useState<any[]>([]); const [kline, setKline] = useState<any[]>([]);
const [capitalFlow, setCapitalFlow] = useState<FlowRecord[]>([]); const [capitalFlow, setCapitalFlow] = useState<FlowRecord[]>([]);
@ -83,11 +95,28 @@ export default function StockDetailPage() {
setKline(k); setKline(k);
setCapitalFlow(c as FlowRecord[]); setCapitalFlow(c as FlowRecord[]);
}); });
// 尝试从推荐历史中获取该股票的评分
fetchAPI<DayGroup[]>(`/api/recommendations/history?days=14`).then((history) => {
for (const group of history) {
const rec = group.recommendations?.find((r) => r.ts_code === code);
if (rec && rec.supply_demand_score) {
setRecScore({
supply_demand_score: rec.supply_demand_score,
price_action_score: rec.price_action_score ?? 0,
technical_score: rec.technical_score,
position_score: rec.position_score ?? 50,
score: rec.score,
});
break;
}
}
}).catch(() => null);
}, [code]); }, [code]);
const latestFlow = capitalFlow.length > 0 ? capitalFlow[capitalFlow.length - 1] : null; const latestFlow = capitalFlow.length > 0 ? capitalFlow[capitalFlow.length - 1] : null;
return ( return (
<ErrorBoundary>
<div className="max-w-6xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-4"> <div className="max-w-6xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-4">
{/* Back */} {/* Back */}
<a <a
@ -116,8 +145,18 @@ export default function StockDetailPage() {
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-lg font-bold tracking-tight">{quote.name}</span> <span className="text-lg font-bold tracking-tight">{quote.name}</span>
<span className="text-xs text-text-muted font-mono tabular-nums">{quote.ts_code}</span> <span className="text-sm text-text-muted font-mono tabular-nums">{quote.ts_code}</span>
</div> </div>
<a
href={`/diagnose?code=${code}`}
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-lg hover:from-amber-500/30 hover:to-amber-600/25 transition-all border border-amber-500/10"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
AI
</a>
</div> </div>
<div className="flex items-baseline gap-3"> <div className="flex items-baseline gap-3">
<span <span
@ -140,14 +179,14 @@ export default function StockDetailPage() {
{quote.pct_chg.toFixed(2)}% {quote.pct_chg.toFixed(2)}%
</span> </span>
{quote.pre_close && ( {quote.pre_close && (
<span className="text-xs text-text-muted font-mono tabular-nums"> <span className="text-sm text-text-muted font-mono tabular-nums">
{quote.pre_close.toFixed(2)} {quote.pre_close.toFixed(2)}
</span> </span>
)} )}
</div> </div>
{/* OHLC row */} {/* OHLC row */}
<div className="grid grid-cols-4 gap-2 mt-4"> <div className="grid grid-cols-4 gap-3 mt-4">
<MiniStat <MiniStat
label="开盘" label="开盘"
value={quote.open && quote.open > 0 ? quote.open.toFixed(2) : "-"} value={quote.open && quote.open > 0 ? quote.open.toFixed(2) : "-"}
@ -176,7 +215,7 @@ export default function StockDetailPage() {
</div> </div>
{/* Valuation row */} {/* Valuation row */}
<div className="grid grid-cols-4 gap-2 mt-2"> <div className="grid grid-cols-4 gap-3 mt-3">
<MiniStat label="换手率" value={`${quote.turnover_rate?.toFixed(2)}%`} /> <MiniStat label="换手率" value={`${quote.turnover_rate?.toFixed(2)}%`} />
<MiniStat label="市盈率" value={quote.pe?.toFixed(1) ?? "-"} /> <MiniStat label="市盈率" value={quote.pe?.toFixed(1) ?? "-"} />
<MiniStat label="市净率" value={quote.pb?.toFixed(2) ?? "-"} /> <MiniStat label="市净率" value={quote.pb?.toFixed(2) ?? "-"} />
@ -188,7 +227,7 @@ export default function StockDetailPage() {
</div> </div>
{/* Market cap row */} {/* Market cap row */}
<div className="grid grid-cols-4 gap-2 mt-2"> <div className="grid grid-cols-4 gap-3 mt-3">
<MiniStat <MiniStat
label="总市值" label="总市值"
value={quote.total_mv ? `${formatBigNum(quote.total_mv)}亿` : "-"} value={quote.total_mv ? `${formatBigNum(quote.total_mv)}亿` : "-"}
@ -217,41 +256,32 @@ export default function StockDetailPage() {
{/* Position Safety Card */} {/* Position Safety Card */}
{signals && ( {signals && (
<div className="glass-card-static p-5"> <div className="glass-card-static p-5">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4"> <div className="flex items-center gap-2 mb-4">
<span className="w-1 h-4 rounded-full" style={{ backgroundColor: getPositionColor(signals.position_score) }} />
<h2 className="text-sm font-bold tracking-tight">
</h2> </h2>
<div className="flex items-center gap-6"> <span className={`text-lg font-bold font-mono tabular-nums ml-auto`} style={{ color: getPositionColor(signals.position_score) }}>
{/* Score ring */}
<div className="relative w-24 h-24 flex-shrink-0">
<svg viewBox="0 0 100 100" className="w-full h-full -rotate-90">
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--surface-2)" strokeWidth="8" />
<circle
cx="50"
cy="50"
r="42"
fill="none"
stroke={getPositionColor(signals.position_score)}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${(signals.position_score / 100) * 264} 264`}
className="transition-all duration-700"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-xl font-bold font-mono tabular-nums ${getPositionColor(signals.position_score)}`}>
{Math.round(signals.position_score)} {Math.round(signals.position_score)}
<span className="text-[10px] text-text-muted ml-0.5"></span>
</span> </span>
<span className="text-[10px] text-text-muted"></span> </div>
{/* 位置安全评分条 */}
<div className="mb-4">
<div className="h-2.5 bg-surface-3 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-700 ease-out"
style={{ width: `${Math.min(signals.position_score, 100)}%`, backgroundColor: getPositionColor(signals.position_score) }}
/>
</div> </div>
</div> </div>
{/* Metrics */} {/* Metrics */}
<div className="flex-1 space-y-3 min-w-0"> <div className="grid grid-cols-3 gap-3">
<PositionBar label="5日涨幅" value={signals.rally_pct_5d} /> <PositionBar label="5日涨幅" value={signals.rally_pct_5d} />
<PositionBar label="10日涨幅" value={signals.rally_pct_10d} /> <PositionBar label="10日涨幅" value={signals.rally_pct_10d} />
<PositionBar label="距高点" value={signals.distance_from_high} invert /> <PositionBar label="距高点" value={signals.distance_from_high} invert />
</div> </div>
</div> </div>
</div>
)} )}
{/* Capital Flow Breakdown */} {/* Capital Flow Breakdown */}
@ -263,45 +293,73 @@ export default function StockDetailPage() {
{/* Technical signals */} {/* Technical signals */}
{signals && ( {signals && (
<div className="glass-card-static p-5 animate-fade-in-up delay-75"> <div className="glass-card-static p-5 animate-fade-in-up delay-75">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-5">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider"> <h2 className="text-sm font-bold tracking-tight">
</h2> </h2>
<div className={`text-lg font-bold font-mono tabular-nums ${getScoreColor(signals.score)}`}> {recScore && (
{signals.score} <div className={`text-2xl font-bold font-mono tabular-nums ${getScoreColor(recScore.score)}`}>
<span className="text-xs text-text-muted ml-0.5"></span> {recScore.score}
<span className="text-xs text-text-muted ml-1"></span>
</div>
)}
</div>
{/* ── Module 1: 核心评分维度 ── */}
<div className="mb-5 pb-5 border-b border-border-subtle">
<div className="flex items-center gap-2 mb-3">
<span className="w-1 h-4 rounded-full bg-amber-500/70" />
<span className="text-xs font-semibold text-text-secondary"></span>
{recScore && (
<span className="text-[10px] text-text-muted/40 ml-1"> = ×50% + ×40% + ×10%</span>
)}
</div>
{recScore ? (
<div className="grid grid-cols-4 gap-3">
<DimensionScore label="供需关系" sublabel="50%权重" value={recScore.supply_demand_score} />
<DimensionScore label="价格形态" sublabel="40%权重" value={recScore.price_action_score} />
<DimensionScore label="趋势方向" sublabel="10%权重" value={recScore.technical_score} />
<DimensionScore label="位置安全" sublabel="防追高" value={recScore.position_score} />
</div>
) : (
<div className="grid grid-cols-2 gap-4">
<DimensionScore label="趋势评分" sublabel="均线排列+结构+MA20方向" value={signals.trend_score} />
<DimensionScore label="信号计数" sublabel="触发即加分,仅供参考" value={(signals.signal_count / 7) * 100} displayValue={`${signals.signal_count}/7`} />
</div>
)}
</div>
{/* ── Module 2: 辅助信号 ── */}
<div className="mb-5 pb-5 border-b border-border-subtle">
<div className="flex items-center gap-2 mb-3">
<span className="w-1 h-4 rounded-full bg-cyan-500/70" />
<span className="text-xs font-semibold text-text-secondary"></span>
<span className="text-[10px] text-text-muted/30">·</span>
<span className={`text-xs font-mono tabular-nums ml-auto ${signals.signal_count >= 4 ? "text-amber-400" : signals.signal_count >= 2 ? "text-cyan-400" : "text-text-muted"}`}>
{signals.signal_count}/7
</span>
</div>
<div className="grid grid-cols-4 gap-2">
<SignalChip label="均线多头" active={signals.ma_bullish} points={15} />
<SignalChip label="放量突破" active={signals.volume_breakout} points={20} />
<SignalChip label="MACD金叉" active={signals.macd_golden} points={15} />
<SignalChip label="RSI健康" active={signals.rsi_healthy} points={10} />
<SignalChip label="缩量回踩" active={signals.pullback_support} points={15} />
<SignalChip label="放量长阳" active={signals.big_yang} points={15} />
<SignalChip label="布林支撑" active={signals.boll_support} points={10} />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2"> {/* ── Module 3: 关键价位 ── */}
<SignalItem label="均线多头" active={signals.ma_bullish} points={15} />
<SignalItem label="放量突破" active={signals.volume_breakout} points={20} />
<SignalItem label="MACD金叉" active={signals.macd_golden} points={15} />
<SignalItem label="RSI健康" active={signals.rsi_healthy} points={10} />
<SignalItem label="缩量回踩" active={signals.pullback_support} points={15} />
<SignalItem label="放量长阳" active={signals.big_yang} points={15} />
<SignalItem label="布林支撑" active={signals.boll_support} points={10} />
</div>
{/* Price levels */}
<div className="flex justify-between mt-4 pt-4 border-t border-border-subtle text-xs">
<div> <div>
<span className="text-text-muted"> </span> <div className="flex items-center gap-2 mb-3">
<span className="text-orange-400 font-mono tabular-nums"> <span className="w-1 h-4 rounded-full bg-emerald-500/70" />
{signals.support_price?.toFixed(2) ?? "-"} <span className="text-xs font-semibold text-text-secondary"></span>
</span>
</div> </div>
<div> <div className="grid grid-cols-3 gap-3">
<span className="text-text-muted"> </span> <PriceLevel label="支撑位" value={signals.support_price} color="text-orange-400" />
<span className="text-red-400 font-mono tabular-nums"> <PriceLevel label="压力位" value={signals.resist_price} color="text-red-400" />
{signals.resist_price?.toFixed(2) ?? "-"} <PriceLevel label="止损位" value={signals.stop_loss_price} color="text-emerald-400" />
</span>
</div>
<div>
<span className="text-text-muted"> </span>
<span className="text-emerald-400 font-mono tabular-nums">
{signals.stop_loss_price?.toFixed(2) ?? "-"}
</span>
</div> </div>
</div> </div>
</div> </div>
@ -313,6 +371,7 @@ export default function StockDetailPage() {
{capitalFlow.length > 0 && <CapitalFlowChart data={capitalFlow} />} {capitalFlow.length > 0 && <CapitalFlowChart data={capitalFlow} />}
</div> </div>
</div> </div>
</ErrorBoundary>
); );
} }
@ -330,9 +389,9 @@ function MiniStat({
highlight?: boolean; highlight?: boolean;
}) { }) {
return ( return (
<div className={`rounded-lg px-2.5 py-1.5 border ${highlight ? "border-amber-500/20 bg-amber-500/[0.04]" : "border-border-subtle bg-surface-1"}`}> <div className={`rounded-xl px-3 py-2.5 border ${highlight ? "border-amber-500/20 bg-amber-500/[0.04]" : "border-border-subtle bg-surface-1"}`}>
<div className="text-[10px] text-text-muted leading-tight">{label}</div> <div className="text-[11px] text-text-muted leading-tight">{label}</div>
<div className={`text-xs font-mono tabular-nums ${color ?? ""}`}> <div className={`text-sm font-bold font-mono tabular-nums mt-0.5 ${color ?? ""}`}>
{value} {value}
</div> </div>
</div> </div>
@ -345,33 +404,19 @@ function PositionBar({ label, value, invert = false }: { label: string; value: n
const pct = Math.min(absVal / maxDisplay, 1) * 100; const pct = Math.min(absVal / maxDisplay, 1) * 100;
const isPositive = value > 0; const isPositive = value > 0;
const showWarning = invert ? value < -20 : value > 20; const showWarning = invert ? value < -20 : value > 20;
const barColor = showWarning ? "bg-amber-400" : isPositive ? "bg-red-400" : "bg-emerald-400";
const textColor = showWarning ? "text-amber-400" : isPositive ? "text-red-400" : "text-emerald-400";
return ( return (
<div> <div className="bg-surface-1 rounded-xl px-3 py-2.5 border border-border-subtle">
<div className="flex items-center justify-between text-xs mb-1"> <div className="text-[10px] text-text-muted leading-tight">{label}</div>
<span className="text-text-muted">{label}</span> <div className={`text-sm font-bold font-mono tabular-nums ${textColor}`}>
<span
className={`font-mono tabular-nums ${
showWarning
? "text-amber-400"
: isPositive
? "text-red-400"
: "text-emerald-400"
}`}
>
{isPositive ? "+" : ""} {isPositive ? "+" : ""}
{value.toFixed(1)}% {value.toFixed(1)}%
</span>
</div> </div>
<div className="h-1.5 rounded-full bg-surface-2 overflow-hidden"> <div className="h-1.5 rounded-full bg-surface-3 overflow-hidden mt-1.5">
<div <div
className={`h-full rounded-full transition-all duration-500 ${ className={`h-full rounded-full transition-all duration-500 ${barColor}`}
showWarning
? "bg-amber-400"
: isPositive
? "bg-red-400"
: "bg-emerald-400"
}`}
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>
@ -384,28 +429,28 @@ function CapitalFlowBreakdown({ flow }: { flow: FlowRecord }) {
...[flow.elg_net, flow.lg_net, flow.md_net, flow.sm_net].map(Math.abs), ...[flow.elg_net, flow.lg_net, flow.md_net, flow.sm_net].map(Math.abs),
1 1
); );
const isMainInflow = flow.main_net_inflow > 0;
return ( return (
<div className="glass-card-static p-5"> <div className="glass-card-static p-5">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4"> <div className="flex items-center gap-2 mb-4">
<span className={`w-1 h-4 rounded-full ${isMainInflow ? "bg-red-500/70" : "bg-emerald-500/70"}`} />
<h2 className="text-sm font-bold tracking-tight">
</h2> </h2>
<span
className={`text-lg font-bold font-mono tabular-nums ml-auto ${isMainInflow ? "text-red-400" : "text-emerald-400"}`}
>
{isMainInflow ? "+" : ""}
{formatFlowAmount(flow.main_net_inflow)}
<span className="text-[10px] text-text-muted ml-0.5"></span>
</span>
</div>
<div className="space-y-2.5"> <div className="space-y-2.5">
<FlowBar label="特大单" value={flow.elg_net} max={maxDisplay} /> <FlowBar label="特大单" value={flow.elg_net} max={maxDisplay} />
<FlowBar label="大单" value={flow.lg_net} max={maxDisplay} /> <FlowBar label="大单" value={flow.lg_net} max={maxDisplay} />
<FlowBar label="中单" value={flow.md_net} max={maxDisplay} /> <FlowBar label="中单" value={flow.md_net} max={maxDisplay} />
<FlowBar label="小单" value={flow.sm_net} max={maxDisplay} /> <FlowBar label="小单" value={flow.sm_net} max={maxDisplay} />
</div> </div>
<div className="mt-3 pt-3 border-t border-border-subtle flex items-center justify-between text-xs">
<span className="text-text-muted"></span>
<span
className={`font-mono tabular-nums font-semibold ${
flow.main_net_inflow > 0 ? "text-red-400" : "text-emerald-400"
}`}
>
{flow.main_net_inflow > 0 ? "+" : ""}
{formatFlowAmount(flow.main_net_inflow)}
</span>
</div>
</div> </div>
); );
} }
@ -438,19 +483,57 @@ function FlowBar({ label, value, max }: { label: string; value: number; max: num
); );
} }
function SignalItem({ label, active, points }: { label: string; active: boolean; points: number }) { function SignalChip({ label, active, points }: { label: string; active: boolean; points: number }) {
return ( return (
<div <div
className={`flex items-center justify-between px-3 py-2 rounded-xl text-xs transition-all duration-200 ${ className={`flex flex-col items-center py-2.5 rounded-xl transition-all duration-200 ${
active active
? "bg-red-500/[0.08] text-red-400 border border-red-500/10" ? "bg-red-500/[0.08] border border-red-500/15"
: "bg-surface-1 text-text-muted border border-transparent" : "bg-surface-1 border border-transparent"
}`} }`}
> >
<span className="font-medium">{label}</span> <span className={`text-[11px] font-medium ${active ? "text-red-400" : "text-text-muted/60"}`}>
<span className={`font-mono tabular-nums ${active ? "font-semibold" : ""}`}> {label}
{active ? `+${points}` : "0"}
</span> </span>
<span className={`text-[11px] font-mono tabular-nums mt-0.5 ${active ? "text-amber-400 font-semibold" : "text-text-muted/30"}`}>
{active ? `+${points}` : "—"}
</span>
</div>
);
}
function DimensionScore({ label, sublabel, value, displayValue }: { label: string; sublabel: string; value: number; displayValue?: string }) {
const width = Math.min(value, 100);
const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low";
const scoreColor = value >= 70 ? "text-amber-400" : value >= 50 ? "text-cyan-400" : "text-text-muted";
return (
<div>
<div className="flex items-baseline justify-between mb-1.5">
<div>
<span className={`text-xs font-semibold ${scoreColor}`}>{label}</span>
<span className="text-[10px] text-text-muted/40 ml-1">{sublabel}</span>
</div>
<span className={`text-sm font-bold font-mono tabular-nums ${scoreColor}`}>
{displayValue ?? value.toFixed(0)}
</span>
</div>
<div className="h-2 bg-surface-3 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-700 ease-out ${gradientClass}`}
style={{ width: `${width}%` }}
/>
</div>
</div>
);
}
function PriceLevel({ label, value, color }: { label: string; value: number | null; color: string }) {
return (
<div className="bg-surface-1 rounded-xl px-3 py-2.5 border border-border-subtle">
<div className="text-[10px] text-text-muted leading-tight">{label}</div>
<div className={`text-sm font-bold font-mono tabular-nums ${color}`}>
{value?.toFixed(2) ?? "—"}
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,37 @@
"use client";
import { Component, ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="glass-card-static p-8 text-center">
<div className="text-sm text-red-400 mb-2"></div>
<button
onClick={() => this.setState({ hasError: false })}
className="text-xs px-3 py-1.5 bg-surface-2 rounded-lg text-text-secondary hover:text-text-primary transition-all"
>
</button>
</div>
);
}
return this.props.children;
}
}

View File

@ -25,6 +25,7 @@ export default function KlineChart({ data }: { data: KlineData[] }) {
if (!chartRef.current || !data.length) return; if (!chartRef.current || !data.length) return;
let chart: ReturnType<typeof import("echarts")["init"]> | null = null; let chart: ReturnType<typeof import("echarts")["init"]> | null = null;
let resizeHandler: (() => void) | null = null;
import("echarts").then((ec) => { import("echarts").then((ec) => {
if (!chartRef.current) return; if (!chartRef.current) return;
@ -141,12 +142,14 @@ export default function KlineChart({ data }: { data: KlineData[] }) {
], ],
}); });
const handleResize = () => chart?.resize(); resizeHandler = () => chart?.resize();
window.addEventListener("resize", handleResize); window.addEventListener("resize", resizeHandler);
return () => window.removeEventListener("resize", handleResize);
}); });
return () => { return () => {
if (resizeHandler) {
window.removeEventListener("resize", resizeHandler);
}
chart?.dispose(); chart?.dispose();
}; };
}, [data, theme]); }, [data, theme]);

View File

@ -53,6 +53,14 @@ function DiagnoseIcon() {
); );
} }
function ChatIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />
</svg>
);
}
function UsersIcon() { function UsersIcon() {
return ( return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
@ -93,6 +101,7 @@ export function SidebarNav() {
<SideNavItem href="/monitor" icon={<MonitorIcon />} label="监控" /> <SideNavItem href="/monitor" icon={<MonitorIcon />} label="监控" />
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块分析" /> <SideNavItem href="/sectors" icon={<FireIcon />} label="板块分析" />
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="AI 诊断" /> <SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="AI 诊断" />
<SideNavItem href="/chat" icon={<ChatIcon />} label="AI 对话" />
{user?.role === "admin" && ( {user?.role === "admin" && (
<SideNavItem href="/users" icon={<UsersIcon />} label="用户管理" /> <SideNavItem href="/users" icon={<UsersIcon />} label="用户管理" />
)} )}
@ -136,6 +145,9 @@ export function MobileBottomNav() {
<MobileNavItem href="/diagnose" label="诊断"> <MobileNavItem href="/diagnose" label="诊断">
<DiagnoseIcon /> <DiagnoseIcon />
</MobileNavItem> </MobileNavItem>
<MobileNavItem href="/chat" label="对话">
<ChatIcon />
</MobileNavItem>
</div> </div>
</nav> </nav>
); );

View File

@ -1,12 +1,20 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useRef, useEffect } from "react";
import { getLevelBadge, getSignalColor, getScoreColor } from "@/lib/utils"; import { getLevelBadge, getScoreColor } from "@/lib/utils";
import type { RecommendationData } from "@/lib/api"; import type { RecommendationData } from "@/lib/api";
export default function StockCard({ rec, showLLMLoading = false }: { rec: RecommendationData; showLLMLoading?: boolean }) { export default function StockCard({ rec, showLLMLoading = false }: { rec: RecommendationData; showLLMLoading?: boolean }) {
const badge = getLevelBadge(rec.level); const badge = getLevelBadge(rec.level);
const [aiExpanded, setAiExpanded] = useState(false); const [aiExpanded, setAiExpanded] = useState(false);
const aiContentRef = useRef<HTMLDivElement>(null);
const [aiContentHeight, setAiContentHeight] = useState(0);
useEffect(() => {
if (aiContentRef.current) {
setAiContentHeight(aiContentRef.current.scrollHeight);
}
}, [aiExpanded, rec.llm_analysis]);
// 入场信号标签 // 入场信号标签
const signalTypeMap: Record<string, { label: string; style: string }> = { const signalTypeMap: Record<string, { label: string; style: string }> = {
@ -23,134 +31,127 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
: null; : null;
const tag = signalInfo || legacyStrategy; const tag = signalInfo || legacyStrategy;
const hasLLM = rec.llm_analysis && rec.llm_analysis !== "AI 分析暂时不可用";
return ( return (
<a <div className="glass-card p-4 group">
href={`/stock/${rec.ts_code}`} {/* Clickable top section — navigates to stock detail */}
className="block glass-card p-3 sm:p-4 md:p-5 group" <a href={`/stock/${rec.ts_code}`} className="block">
>
{/* Header: Name + Strategy + Score */} {/* Header: Name + Strategy + Score */}
<div className="flex items-start justify-between mb-2 sm:mb-3"> <div className="flex items-start justify-between mb-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<span className="font-semibold text-sm tracking-tight">{rec.name}</span> <span className="font-semibold text-sm tracking-tight">{rec.name}</span>
<span className={`text-[10px] sm:text-xs px-1 py-0.5 rounded-full font-medium ${badge.bg} ${badge.text}`}> {rec.signal === "BUY" && (
{rec.level} <span className="text-[10px] px-1.5 py-0.5 rounded-md font-medium bg-red-500/15 text-red-400 border border-red-500/20">
</span> </span>
)}
{tag && ( {tag && (
<span className={`text-[9px] sm:text-[10px] px-1 py-0.5 rounded-md font-medium border ${tag.style}`}> <span className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium border ${tag.style}`}>
{tag.label} {tag.label}
</span> </span>
)} )}
</div> </div>
<div className="text-[10px] sm:text-[11px] text-text-muted mt-0.5 sm:mt-1 font-mono tabular-nums truncate"> <div className="text-[11px] text-text-muted mt-1 font-mono tabular-nums">
{rec.ts_code} <span className="text-text-muted/40 mx-0.5">·</span> {rec.sector} {rec.ts_code} · {rec.sector}
</div> </div>
</div> </div>
<div className="text-right shrink-0 ml-2 sm:ml-3"> <div className="text-right shrink-0 ml-3">
<div className={`text-lg sm:text-xl font-bold font-mono tabular-nums tracking-tight ${getScoreColor(rec.score)}`}> <div className={`text-xl font-bold font-mono tabular-nums tracking-tight ${getScoreColor(rec.score)}`}>
{rec.score} {rec.score}
</div> </div>
<div className={`text-[10px] sm:text-xs font-semibold tracking-wider ${getSignalColor(rec.signal)}`}> <div className={`text-[10px] font-medium ${badge.text}`}>
{rec.signal === "BUY" ? "买入" : rec.signal === "SELL" ? "卖出" : "持有"} {rec.level}
</div> </div>
</div> </div>
</div> </div>
{/* Four dimension score bars */} {/* Score dimension bars */}
<div className="grid grid-cols-4 gap-1 sm:gap-2 mb-3 sm:mb-4"> <div className="grid grid-cols-4 gap-2 mb-3">
<ScoreBar label="市场" value={rec.market_temp_score} /> <ScoreBar label="供需" value={rec.supply_demand_score ?? 0} weight="50%" />
<ScoreBar label="板块" value={rec.sector_score} /> <ScoreBar label="形态" value={rec.price_action_score ?? 0} weight="40%" />
<ScoreBar label="资金" value={rec.capital_score} /> <ScoreBar label="趋势" value={rec.technical_score} weight="10%" />
<ScoreBar label="技术" value={rec.technical_score} /> <ScoreBar label="位置" value={rec.position_score ?? 50} weight="防追高" />
</div> </div>
{/* Price reference */} {/* Price reference */}
{rec.entry_price && ( {rec.entry_price && (
<div className="grid grid-cols-3 gap-1.5 sm:gap-2 mb-2 sm:mb-3 bg-surface-2 rounded-lg sm:rounded-xl px-2.5 sm:px-3 md:px-4 py-2 border border-border-subtle text-[10px] sm:text-xs"> <div className="grid grid-cols-3 gap-2 mb-2 bg-surface-1/60 rounded-lg px-3 py-2 border border-border-subtle">
<div> <div>
<span className="text-text-muted block text-[9px] sm:text-[10px]"></span> <span className="text-text-muted/60 block text-[10px]"></span>
<span className="text-red-400 font-mono tabular-nums">{rec.entry_price}</span> <span className="text-red-400 font-mono tabular-nums text-xs">{rec.entry_price}</span>
</div> </div>
<div> <div>
<span className="text-text-muted block text-[9px] sm:text-[10px]"></span> <span className="text-text-muted/60 block text-[10px]"></span>
<span className="text-amber-400 font-mono tabular-nums">{rec.target_price}</span> <span className="text-amber-400 font-mono tabular-nums text-xs">{rec.target_price}</span>
</div> </div>
<div> <div>
<span className="text-text-muted block text-[9px] sm:text-[10px]"></span> <span className="text-text-muted/60 block text-[10px]"></span>
<span className="text-emerald-400 font-mono tabular-nums">{rec.stop_loss}</span> <span className="text-emerald-400 font-mono tabular-nums text-xs">{rec.stop_loss}</span>
</div> </div>
</div> </div>
)} )}
{/* Reasons - show max 2 on mobile, 3 on desktop */} {/* Reasons */}
<div className="space-y-1 sm:space-y-1.5"> <div className="space-y-1.5">
{rec.reasons.slice(0, 2).map((r, i) => ( {rec.reasons.slice(0, 3).map((r, i) => (
<div key={i} className="text-[10px] sm:text-xs text-text-secondary flex items-start gap-1.5 sm:gap-2"> <div key={i} className="text-xs text-text-secondary flex items-start gap-2">
<span className="w-1 h-1 rounded-full bg-amber-500/60 mt-[5px] sm:mt-[7px] shrink-0" /> <span className="w-1 h-1 rounded-full bg-amber-500/60 mt-[6px] shrink-0" />
<span className="leading-relaxed line-clamp-2">{r}</span> <span className="leading-relaxed line-clamp-2">{r}</span>
</div> </div>
))} ))}
{rec.reasons.length > 2 && (
<div className="text-[9px] sm:text-[10px] text-text-muted/60">+{rec.reasons.length - 2}</div>
)}
</div> </div>
</a>
{/* AI Analysis — collapsible */} {/* ── AI Analysis — separate from the clickable link area ── */}
{rec.llm_analysis && rec.llm_analysis !== "AI 分析暂时不可用" ? ( {hasLLM ? (
<div className="mt-2 sm:mt-3"> <div className="mt-3 border-t border-border-subtle pt-3">
<button <button
onClick={(e) => { e.preventDefault(); setAiExpanded(!aiExpanded); }} onClick={() => setAiExpanded(!aiExpanded)}
className="w-full flex items-center justify-between bg-accent-cyan/[0.06] border border-accent-cyan/[0.12] rounded-lg sm:rounded-xl px-3 sm:px-4 py-2 hover:bg-accent-cyan/[0.09] transition-colors" className="w-full flex items-center gap-2 text-xs text-cyan-400/80 font-medium hover:text-cyan-400 transition-colors"
> >
<div className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs text-accent-cyan/80 font-semibold tracking-wider"> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${aiExpanded ? "rotate-90" : ""}`}>
<svg width="10" height="10" 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" /> <path d="M9 18l6-6-6-6" />
</svg> </svg>
AI AI
</div>
{rec.llm_score != null && ( {rec.llm_score != null && (
<div className="text-[10px] sm:text-xs font-mono tabular-nums"> <span className="ml-auto font-mono tabular-nums">
<span className="text-text-muted"> </span>
<span className={`font-bold ${rec.llm_score >= 7 ? "text-amber-400" : rec.llm_score >= 5 ? "text-cyan-400" : "text-text-muted"}`}> <span className={`font-bold ${rec.llm_score >= 7 ? "text-amber-400" : rec.llm_score >= 5 ? "text-cyan-400" : "text-text-muted"}`}>
{rec.llm_score} {rec.llm_score}
</span> </span>
<span className="text-text-muted/50">/10</span> <span className="text-text-muted/40">/10</span>
</div> </span>
)} )}
</button> </button>
{aiExpanded && ( <div
<div className="text-[10px] sm: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-lg sm:rounded-b-xl px-3 sm:px-4 py-2 sm:py-3"> className="overflow-hidden transition-[max-height] duration-300 ease-out"
<MarkdownText text={rec.llm_analysis} /> style={{ maxHeight: aiExpanded ? aiContentHeight + 20 : 0 }}
>
<div ref={aiContentRef} className="text-xs text-text-secondary leading-relaxed whitespace-pre-line mt-2 pl-1">
<MarkdownText text={rec.llm_analysis ?? ""} />
</div>
</div> </div>
)}
</div> </div>
) : rec.llm_analysis === "AI 分析暂时不可用" ? ( ) : rec.llm_analysis === "AI 分析暂时不可用" ? (
<div className="mt-2 sm:mt-3 text-[10px] sm:text-xs text-text-muted/50 flex items-center gap-1.5"> <div className="mt-3 border-t border-border-subtle pt-2 text-xs text-text-muted/40 flex items-center gap-1.5">
<span className="w-1 h-1 rounded-full bg-text-muted/30" /> <span className="w-1 h-1 rounded-full bg-text-muted/20" />
AI AI
</div> </div>
) : showLLMLoading ? ( ) : showLLMLoading ? (
<div className="mt-2 sm:mt-3 text-[10px] sm:text-xs text-text-muted flex items-center gap-2"> <div className="mt-3 border-t border-border-subtle pt-2 text-xs text-text-muted flex items-center gap-2">
<span className="inline-block w-3 h-3 border border-accent-cyan/30 border-t-accent-cyan/80 rounded-full animate-spin" /> <span className="inline-block w-3 h-3 border border-cyan-400/30 border-t-cyan-400/80 rounded-full animate-spin" />
AI ... AI ...
</div> </div>
) : null} ) : null}
{/* Risk note */} {/* Risk note */}
{rec.risk_note && ( {rec.risk_note && (
<div className="mt-2 sm:mt-3 text-[10px] sm:text-xs text-amber-500/60 bg-amber-500/[0.04] border border-amber-500/[0.08] rounded-lg px-2.5 sm:px-3 py-1.5"> <div className="mt-2 text-[11px] text-amber-500/50 bg-amber-500/[0.04] rounded-lg px-3 py-1.5">
{rec.risk_note} {rec.risk_note}
</div> </div>
)} )}
{/* Hover indicator - hidden on mobile */}
<div className="mt-2 sm:mt-3 hidden sm:flex items-center gap-1 text-xs text-text-muted opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<span></span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</div> </div>
</a>
); );
} }
@ -234,16 +235,16 @@ function renderInlineFormat(text: string) {
}); });
} }
function ScoreBar({ label, value }: { label: string; value: number }) { function ScoreBar({ label, value, weight }: { label: string; value: number; weight?: string }) {
const width = Math.min(value, 100); const width = Math.min(value, 100);
const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low"; const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low";
return ( return (
<div> <div>
<div className="flex justify-between text-[9px] sm:text-[10px] xs:text-xs text-text-muted mb-0.5 sm:mb-1"> <div className="flex justify-between text-[10px] text-text-muted mb-1">
<span className="font-medium">{label}</span> <span className="font-medium">{label}{weight ? <span className="text-text-muted/30 ml-0.5">{weight}</span> : null}</span>
<span className="font-mono tabular-nums">{value.toFixed(0)}</span> <span className="font-mono tabular-nums">{value.toFixed(0)}</span>
</div> </div>
<div className="h-1 bg-surface-3 rounded-full overflow-hidden"> <div className="h-1.5 bg-surface-3 rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all duration-700 ease-out ${gradientClass}`} className={`h-full rounded-full transition-all duration-700 ease-out ${gradientClass}`}
style={{ width: `${width}%` }} style={{ width: `${width}%` }}

View File

@ -7,7 +7,7 @@ interface WSMessage {
[key: string]: unknown; [key: string]: unknown;
} }
export function useWebSocket(onMessage?: (data: WSMessage) => void) { export function useWebSocket(onMessage?: (data: WSMessage) => void, messageTypes?: string[]) {
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<NodeJS.Timeout>(); const reconnectTimer = useRef<NodeJS.Timeout>();
@ -29,6 +29,7 @@ export function useWebSocket(onMessage?: (data: WSMessage) => void) {
if (event.data === "pong") return; if (event.data === "pong") return;
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (messageTypes && !messageTypes.includes(data.type)) return;
onMessage?.(data); onMessage?.(data);
} catch { } catch {
// ignore // ignore
@ -42,7 +43,7 @@ export function useWebSocket(onMessage?: (data: WSMessage) => void) {
ws.onerror = () => ws.close(); ws.onerror = () => ws.close();
wsRef.current = ws; wsRef.current = ws;
}, [onMessage]); }, [onMessage, messageTypes]);
useEffect(() => { useEffect(() => {
connect(); connect();

View File

@ -102,6 +102,8 @@ export interface RecommendationData {
sector_score: number; sector_score: number;
capital_score: number; capital_score: number;
technical_score: number; technical_score: number;
supply_demand_score?: number;
price_action_score?: number;
position_score?: number; position_score?: number;
valuation_score?: number; valuation_score?: number;
entry_price: number | null; entry_price: number | null;

View File

@ -0,0 +1,34 @@
export 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");
}
export function formatMarkdown(text: string): string {
let html = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/^\s*[-*]\s+(.+)/gm, "<li>$1</li>")
.replace(/\n/g, "<br>");
// Wrap consecutive <li> items in <ul>
html = html.replace(/(<li>.*?<\/li>(<br>)?)+/g, (match) => {
return "<ul>" + match.replace(/<br>/g, "") + "</ul>";
});
return html;
}