1
This commit is contained in:
parent
1db602088d
commit
5205fbd8a8
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -53,6 +53,34 @@ async def get_overview():
|
|||||||
return _overview_daily()
|
return _overview_daily()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/daily-review")
|
||||||
|
async def get_daily_review():
|
||||||
|
"""获取每日复盘报告"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.db.database import get_db
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text("SELECT * FROM daily_reviews ORDER BY trade_date DESC LIMIT 5")
|
||||||
|
)
|
||||||
|
reviews = []
|
||||||
|
for row in result.fetchall():
|
||||||
|
r = row._mapping
|
||||||
|
reviews.append({
|
||||||
|
"trade_date": r["trade_date"],
|
||||||
|
"content": r["content"] or "",
|
||||||
|
"created_at": str(r["created_at"]) if r["created_at"] else "",
|
||||||
|
})
|
||||||
|
return {"reviews": reviews}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate-review")
|
||||||
|
async def generate_daily_review():
|
||||||
|
"""手动触发生成每日复盘"""
|
||||||
|
from app.llm.daily_review import generate_review
|
||||||
|
result = await generate_review()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def _overview_realtime():
|
async def _overview_realtime():
|
||||||
"""盘中:腾讯实时指数行情"""
|
"""盘中:腾讯实时指数行情"""
|
||||||
index_data = await tencent_client.get_index_realtime()
|
index_data = await tencent_client.get_index_realtime()
|
||||||
|
|||||||
106
backend/app/api/monitor.py
Normal file
106
backend/app/api/monitor.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"""涨跌停/异动监控 API"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.data.tushare_client import tushare_client
|
||||||
|
from app.config import is_market_session
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/monitor", tags=["monitor"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/limits")
|
||||||
|
async def get_limits():
|
||||||
|
"""获取涨跌停数据"""
|
||||||
|
trade_date = tushare_client.get_latest_trade_date()
|
||||||
|
limit_df = tushare_client.get_limit_list(trade_date)
|
||||||
|
|
||||||
|
if limit_df.empty:
|
||||||
|
return {"trade_date": trade_date, "limit_up": [], "limit_down": []}
|
||||||
|
|
||||||
|
# 拆分涨停和跌停
|
||||||
|
up_df = limit_df[limit_df["limit"] == "U"].sort_values("pct_chg", ascending=False)
|
||||||
|
down_df = limit_df[limit_df["limit"] == "D"].sort_values("pct_chg", ascending=True)
|
||||||
|
|
||||||
|
def _parse(df):
|
||||||
|
results = []
|
||||||
|
for _, r in df.head(50).iterrows():
|
||||||
|
results.append({
|
||||||
|
"ts_code": r.get("ts_code", ""),
|
||||||
|
"name": r.get("name", ""),
|
||||||
|
"close": float(r.get("close", 0)),
|
||||||
|
"pct_chg": float(r.get("pct_chg", 0)),
|
||||||
|
"limit_times": int(r.get("limit_times", 0)),
|
||||||
|
"first_time": str(r.get("first_time", "")),
|
||||||
|
"last_time": str(r.get("last_time", "")),
|
||||||
|
"open_times": int(r.get("open_times", 0)),
|
||||||
|
"fd_amount": float(r.get("fd_amount", 0)) if r.get("fd_amount") else 0,
|
||||||
|
"up_stat": str(r.get("up_stat", "")),
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
return {
|
||||||
|
"trade_date": trade_date,
|
||||||
|
"is_realtime": is_market_session(),
|
||||||
|
"limit_up": _parse(up_df),
|
||||||
|
"limit_down": _parse(down_df),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unusual")
|
||||||
|
async def get_unusual():
|
||||||
|
"""获取异动股(量比>3、振幅>8%、快速拉升)"""
|
||||||
|
trade_date = tushare_client.get_latest_trade_date()
|
||||||
|
|
||||||
|
# 获取全市场日线
|
||||||
|
daily = tushare_client.get_daily_all(trade_date)
|
||||||
|
if daily.empty:
|
||||||
|
return {"trade_date": trade_date, "stocks": []}
|
||||||
|
|
||||||
|
basic = tushare_client.get_daily_basic(trade_date)
|
||||||
|
if not basic.empty:
|
||||||
|
daily = daily.merge(basic[["ts_code", "turnover_rate", "volume_ratio"]], on="ts_code", how="left")
|
||||||
|
|
||||||
|
stock_basic = tushare_client.get_stock_basic()
|
||||||
|
name_map = {}
|
||||||
|
if not stock_basic.empty:
|
||||||
|
for _, r in stock_basic.iterrows():
|
||||||
|
name_map[r["ts_code"]] = r["name"]
|
||||||
|
|
||||||
|
# 筛选异动条件
|
||||||
|
unusual = []
|
||||||
|
for _, r in daily.iterrows():
|
||||||
|
ts = r.get("ts_code", "")
|
||||||
|
if not ts.endswith((".SH", ".SZ")):
|
||||||
|
continue
|
||||||
|
|
||||||
|
pct = r.get("pct_chg", 0)
|
||||||
|
vol_ratio = r.get("volume_ratio", 0)
|
||||||
|
high = r.get("high", 0)
|
||||||
|
low = r.get("low", 0)
|
||||||
|
pre_close = r.get("pre_close", 0)
|
||||||
|
amplitude = (high - low) / pre_close * 100 if pre_close > 0 else 0
|
||||||
|
|
||||||
|
tags = []
|
||||||
|
if vol_ratio and vol_ratio > 3:
|
||||||
|
tags.append("巨量")
|
||||||
|
if amplitude > 8:
|
||||||
|
tags.append("高振幅")
|
||||||
|
if pct > 7:
|
||||||
|
tags.append("急涨")
|
||||||
|
elif pct < -7:
|
||||||
|
tags.append("急跌")
|
||||||
|
|
||||||
|
if tags:
|
||||||
|
unusual.append({
|
||||||
|
"ts_code": ts,
|
||||||
|
"name": name_map.get(ts, ts),
|
||||||
|
"close": float(r.get("close", 0)),
|
||||||
|
"pct_chg": float(pct),
|
||||||
|
"amplitude": round(float(amplitude), 2),
|
||||||
|
"volume_ratio": round(float(vol_ratio), 2) if vol_ratio and vol_ratio == vol_ratio else 0,
|
||||||
|
"turnover_rate": round(float(r.get("turnover_rate", 0)), 2),
|
||||||
|
"tags": tags,
|
||||||
|
})
|
||||||
|
|
||||||
|
unusual.sort(key=lambda x: abs(x["pct_chg"]), reverse=True)
|
||||||
|
return {"trade_date": trade_date, "stocks": unusual[:50]}
|
||||||
@ -6,6 +6,7 @@ from app.engine.recommender import (
|
|||||||
refresh_recommendations,
|
refresh_recommendations,
|
||||||
get_latest_recommendations,
|
get_latest_recommendations,
|
||||||
get_recommendation_history,
|
get_recommendation_history,
|
||||||
|
get_performance_stats,
|
||||||
)
|
)
|
||||||
from app.config import is_trading_hours
|
from app.config import is_trading_hours
|
||||||
|
|
||||||
@ -91,3 +92,9 @@ async def get_scan_status():
|
|||||||
async def get_history(days: int = 7):
|
async def get_history(days: int = 7):
|
||||||
"""获取历史推荐(按日期分组)"""
|
"""获取历史推荐(按日期分组)"""
|
||||||
return await get_recommendation_history(days)
|
return await get_recommendation_history(days)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/performance")
|
||||||
|
async def performance():
|
||||||
|
"""获取推荐胜率统计"""
|
||||||
|
return await get_performance_stats()
|
||||||
|
|||||||
@ -117,3 +117,77 @@ async def get_hot_sectors(limit: int = 10):
|
|||||||
|
|
||||||
sectors_data = await _enrich_sectors_realtime(sectors_data)
|
sectors_data = await _enrich_sectors_realtime(sectors_data)
|
||||||
return sectors_data
|
return sectors_data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rotation")
|
||||||
|
async def get_sector_rotation(days: int = 5):
|
||||||
|
"""获取近N日板块轮动数据(用于热力图)"""
|
||||||
|
trade_date = tushare_client.get_latest_trade_date()
|
||||||
|
|
||||||
|
# 获取交易日历
|
||||||
|
trade_dates_df = tushare_client.get_trade_dates()
|
||||||
|
today = trade_date
|
||||||
|
past_dates = [d for d in trade_dates_df if d <= today]
|
||||||
|
# 取最近 N 天
|
||||||
|
recent_dates = past_dates[-days:] if len(past_dates) >= days else past_dates
|
||||||
|
|
||||||
|
# 获取板块指数列表用于名字映射
|
||||||
|
index_list = tushare_client.get_ths_index_list()
|
||||||
|
name_map = {}
|
||||||
|
if not index_list.empty:
|
||||||
|
for _, row in index_list.iterrows():
|
||||||
|
name_map[row["ts_code"]] = row["name"]
|
||||||
|
|
||||||
|
all_sectors = []
|
||||||
|
for td in recent_dates:
|
||||||
|
df = tushare_client.get_sector_moneyflow(td)
|
||||||
|
if df.empty:
|
||||||
|
continue
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
code = row.get("ts_code", "")
|
||||||
|
# Use industry field from moneyflow data, fallback to name_map
|
||||||
|
industry_name = row.get("industry", "") or name_map.get(code, code)
|
||||||
|
all_sectors.append({
|
||||||
|
"sector_code": code,
|
||||||
|
"sector_name": industry_name,
|
||||||
|
"trade_date": td,
|
||||||
|
"net_amount": round(float(row.get("net_amount", 0) or 0), 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 获取板块日线来补充涨跌幅
|
||||||
|
sector_codes = list(set(s["sector_code"] for s in all_sectors))
|
||||||
|
sector_pct_map: dict[str, dict[str, float]] = {}
|
||||||
|
for code in sector_codes:
|
||||||
|
df_daily = tushare_client.get_ths_daily(code, days=days + 10)
|
||||||
|
if not df_daily.empty:
|
||||||
|
for _, r in df_daily.iterrows():
|
||||||
|
if r["trade_date"] in recent_dates:
|
||||||
|
if code not in sector_pct_map:
|
||||||
|
sector_pct_map[code] = {}
|
||||||
|
sector_pct_map[code][r["trade_date"]] = float(r.get("pct_change", 0) or 0)
|
||||||
|
|
||||||
|
# 按板块分组
|
||||||
|
sector_map: dict[str, dict] = {}
|
||||||
|
for s in all_sectors:
|
||||||
|
code = s["sector_code"]
|
||||||
|
if code not in sector_map:
|
||||||
|
sector_map[code] = {
|
||||||
|
"sector_code": code,
|
||||||
|
"sector_name": s["sector_name"],
|
||||||
|
"daily_data": [],
|
||||||
|
}
|
||||||
|
pct = sector_pct_map.get(code, {}).get(s["trade_date"], 0)
|
||||||
|
sector_map[code]["daily_data"].append({
|
||||||
|
"trade_date": s["trade_date"],
|
||||||
|
"pct_change": round(pct, 2),
|
||||||
|
"net_amount": s["net_amount"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按最近一天涨幅排序,取 top 20
|
||||||
|
sorted_sectors = sorted(
|
||||||
|
sector_map.values(),
|
||||||
|
key=lambda x: max((d["pct_change"] for d in x["daily_data"]), default=0),
|
||||||
|
reverse=True,
|
||||||
|
)[:20]
|
||||||
|
|
||||||
|
return {"trade_date": trade_date, "dates": recent_dates, "sectors": sorted_sectors}
|
||||||
|
|||||||
@ -89,3 +89,147 @@ async def search_stock(keyword: str):
|
|||||||
basic["symbol"].str.contains(keyword, na=False)
|
basic["symbol"].str.contains(keyword, na=False)
|
||||||
].head(20)
|
].head(20)
|
||||||
return matches[["ts_code", "name", "industry"]].to_dict(orient="records")
|
return matches[["ts_code", "name", "industry"]].to_dict(orient="records")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{ts_code}/diagnose")
|
||||||
|
async def diagnose_stock(ts_code: str):
|
||||||
|
"""AI 诊断个股"""
|
||||||
|
from app.config import settings
|
||||||
|
if not settings.deepseek_api_key:
|
||||||
|
return {"status": "error", "message": "未配置 LLM API Key"}
|
||||||
|
|
||||||
|
from app.llm.client import get_client
|
||||||
|
|
||||||
|
# 收集数据
|
||||||
|
quote = await tencent_client.get_realtime_quote(ts_code)
|
||||||
|
signals = generate_signals(ts_code)
|
||||||
|
|
||||||
|
df_daily = tushare_client.get_stock_daily(ts_code, days=30)
|
||||||
|
df_flow = tushare_client.get_stock_moneyflow(ts_code, days=10)
|
||||||
|
|
||||||
|
# 构建数据摘要
|
||||||
|
quote_str = ""
|
||||||
|
if quote:
|
||||||
|
quote_str = (
|
||||||
|
f"当前价: {quote.price}, 涨跌幅: {quote.pct_chg}%, "
|
||||||
|
f"换手率: {quote.turnover_rate}%, 量比: {quote.volume_ratio}, "
|
||||||
|
f"PE: {quote.pe}, PB: {quote.pb}, "
|
||||||
|
f"总市值: {quote.total_mv}亿, 流通市值: {quote.circ_mv}亿"
|
||||||
|
)
|
||||||
|
|
||||||
|
signal_str = (
|
||||||
|
f"技术评分: {signals.score}/100(基于7项技术信号触发计分,触发少不代表一定差,可能处于蓄势阶段), "
|
||||||
|
f"信号数: {signals.signal_count}/7, "
|
||||||
|
f"均线多头: {signals.ma_bullish}, "
|
||||||
|
f"放量突破: {signals.volume_breakout}, "
|
||||||
|
f"MACD金叉: {signals.macd_golden}, "
|
||||||
|
f"RSI健康: {signals.rsi_healthy}, "
|
||||||
|
f"缩量回踩: {signals.pullback_support}, "
|
||||||
|
f"放量长阳: {signals.big_yang}, "
|
||||||
|
f"布林支撑: {signals.boll_support}, "
|
||||||
|
f"支撑位: {signals.support_price}, "
|
||||||
|
f"压力位: {signals.resist_price}, "
|
||||||
|
f"止损位: {signals.stop_loss_price}"
|
||||||
|
)
|
||||||
|
|
||||||
|
position_str = (
|
||||||
|
f"位置安全评分: {signals.position_score}/100(越高表示位置越低越安全,96分以上表示处于相对低位), "
|
||||||
|
f"近5日涨幅: {signals.rally_pct_5d}%, "
|
||||||
|
f"近10日涨幅: {signals.rally_pct_10d}%, "
|
||||||
|
f"距60日高点: {signals.distance_from_high}%"
|
||||||
|
)
|
||||||
|
|
||||||
|
trend_str = ""
|
||||||
|
ma_info = ""
|
||||||
|
if not df_daily.empty:
|
||||||
|
df_daily = df_daily.sort_values("trade_date")
|
||||||
|
latest = df_daily.iloc[-1]
|
||||||
|
if len(df_daily) >= 5:
|
||||||
|
pct_5d = (latest["close"] - df_daily.iloc[-5]["close"]) / df_daily.iloc[-5]["close"] * 100
|
||||||
|
trend_str += f"5日涨幅: {pct_5d:.2f}%, "
|
||||||
|
if len(df_daily) >= 20:
|
||||||
|
pct_20d = (latest["close"] - df_daily.iloc[-20]["close"]) / df_daily.iloc[-20]["close"] * 100
|
||||||
|
trend_str += f"20日涨幅: {pct_20d:.2f}%, "
|
||||||
|
vol_avg_5 = df_daily.tail(5)["vol"].mean()
|
||||||
|
vol_latest = latest["vol"]
|
||||||
|
trend_str += f"量比(5日均): {vol_latest / vol_avg_5:.2f}" if vol_avg_5 > 0 else ""
|
||||||
|
# MA 信息
|
||||||
|
if "ma5" in latest and "ma20" in latest:
|
||||||
|
ma5 = latest.get("ma5", 0)
|
||||||
|
ma10 = latest.get("ma10", 0)
|
||||||
|
ma20 = latest.get("ma20", 0)
|
||||||
|
ma60 = latest.get("ma60", 0)
|
||||||
|
price = latest["close"]
|
||||||
|
ma_info = (
|
||||||
|
f"价格与均线关系: 现价{price:.2f}, "
|
||||||
|
f"MA5={ma5:.2f}, MA10={ma10:.2f}, MA20={ma20:.2f}, MA60={ma60:.2f}, "
|
||||||
|
f"{'价格在MA5上方' if price > ma5 else '价格在MA5下方'}, "
|
||||||
|
f"{'价格在MA20上方' if price > ma20 else '价格在MA20下方'}, "
|
||||||
|
f"{'均线多头排列' if ma5 > ma10 > ma20 else '均线未多头排列'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
flow_str = ""
|
||||||
|
if not df_flow.empty:
|
||||||
|
df_flow = df_flow.sort_values("trade_date")
|
||||||
|
recent_3 = df_flow.tail(3)
|
||||||
|
total_main = 0
|
||||||
|
for _, r in recent_3.iterrows():
|
||||||
|
main_net = (
|
||||||
|
(r.get("buy_elg_amount", 0) or 0) - (r.get("sell_elg_amount", 0) or 0) +
|
||||||
|
(r.get("buy_lg_amount", 0) or 0) - (r.get("sell_lg_amount", 0) or 0)
|
||||||
|
)
|
||||||
|
total_main += main_net
|
||||||
|
flow_str = f"近3日主力净流入: {total_main:.0f}万"
|
||||||
|
|
||||||
|
# 基本信息
|
||||||
|
basic_info = ""
|
||||||
|
basic_df = tushare_client.get_stock_basic()
|
||||||
|
if not basic_df.empty:
|
||||||
|
row = basic_df[basic_df["ts_code"] == ts_code]
|
||||||
|
if not row.empty:
|
||||||
|
r = row.iloc[0]
|
||||||
|
basic_info = f"名称: {r['name']}, 行业: {r.get('industry', '未知')}"
|
||||||
|
|
||||||
|
user_msg = f"""请对以下A股进行全面诊断分析:
|
||||||
|
|
||||||
|
股票: {ts_code} ({basic_info})
|
||||||
|
{quote_str}
|
||||||
|
|
||||||
|
技术面: {signal_str}
|
||||||
|
|
||||||
|
位置安全: {position_str}
|
||||||
|
|
||||||
|
趋势: {trend_str}
|
||||||
|
{ma_info}
|
||||||
|
资金面: {flow_str}
|
||||||
|
|
||||||
|
重要提示:技术评分基于7项信号触发计分,分数低不代表股票差,可能处于蓄势阶段。位置安全评分高(>80)表示股价处于相对低位。请综合技术评分和位置安全评分一起判断。
|
||||||
|
|
||||||
|
请从以下维度分析(Markdown格式,简洁专业):
|
||||||
|
## 综合评级
|
||||||
|
(给出1-5星评级和一句话总结,综合技术面和位置安全评分)
|
||||||
|
|
||||||
|
## 技术面分析
|
||||||
|
(趋势方向、均线关系、支撑压力、量价配合,注意区分"技术信号未触发"和"技术面恶化")
|
||||||
|
|
||||||
|
## 资金面分析
|
||||||
|
(主力资金态度、筹码集中度推测)
|
||||||
|
|
||||||
|
## 操作建议
|
||||||
|
(适合什么类型的投资者、入场时机、风险提示)"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
response = await client.chat.completions.create(
|
||||||
|
model=settings.deepseek_model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "你是一位专业的A股分析师,擅长技术面和资金面分析。回复使用Markdown格式,简洁专业,客观理性。"},
|
||||||
|
{"role": "user", "content": user_msg},
|
||||||
|
],
|
||||||
|
max_tokens=1500,
|
||||||
|
temperature=0.5,
|
||||||
|
)
|
||||||
|
content = response.choices[0].message.content.strip()
|
||||||
|
return {"status": "ok", "ts_code": ts_code, "diagnosis": content}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|||||||
Binary file not shown.
@ -83,3 +83,11 @@ users_table = Table(
|
|||||||
Column("created_at", DateTime, server_default=func.now()),
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
Column("updated_at", DateTime, server_default=func.now()),
|
Column("updated_at", DateTime, server_default=func.now()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
daily_reviews_table = Table(
|
||||||
|
"daily_reviews", metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("trade_date", Text, nullable=False, unique=True),
|
||||||
|
Column("content", Text, default=""),
|
||||||
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -34,6 +34,9 @@ async def refresh_recommendations(trade_date: str = None, scan_session: str = "m
|
|||||||
# 持久化到数据库
|
# 持久化到数据库
|
||||||
await _save_to_db(result)
|
await _save_to_db(result)
|
||||||
|
|
||||||
|
# 更新历史推荐跟踪(检查之前推荐的后续表现)
|
||||||
|
await _update_tracking()
|
||||||
|
|
||||||
# 异步 AI 深度分析(不阻塞返回)
|
# 异步 AI 深度分析(不阻塞返回)
|
||||||
if settings.deepseek_api_key:
|
if settings.deepseek_api_key:
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -43,6 +46,210 @@ async def refresh_recommendations(trade_date: str = None, scan_session: str = "m
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_tracking():
|
||||||
|
"""更新历史推荐的跟踪数据"""
|
||||||
|
try:
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.data.tushare_client import tushare_client
|
||||||
|
|
||||||
|
trade_date = tushare_client.get_latest_trade_date()
|
||||||
|
|
||||||
|
async with get_db() as db:
|
||||||
|
# 查找所有活跃的推荐(有 entry_price 且未被标记为 closed)
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, ts_code, entry_price, target_price, stop_loss "
|
||||||
|
"FROM recommendations "
|
||||||
|
"WHERE entry_price IS NOT NULL "
|
||||||
|
"AND entry_price > 0 "
|
||||||
|
"AND id NOT IN (SELECT DISTINCT recommendation_id FROM recommendation_tracking WHERE status = 'closed') "
|
||||||
|
"AND date(created_at) <= date(:today) "
|
||||||
|
"ORDER BY created_at DESC LIMIT 50"
|
||||||
|
),
|
||||||
|
{"today": datetime.now().strftime("%Y-%m-%d")},
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取这些股票的今日收盘价
|
||||||
|
codes = [r[1] for r in rows]
|
||||||
|
daily_all = tushare_client.get_daily_all(trade_date)
|
||||||
|
price_map = {}
|
||||||
|
if not daily_all.empty:
|
||||||
|
for _, row in daily_all.iterrows():
|
||||||
|
if row["ts_code"] in codes:
|
||||||
|
price_map[row["ts_code"]] = row["close"]
|
||||||
|
|
||||||
|
tracked = 0
|
||||||
|
for r in rows:
|
||||||
|
rec_id, ts_code, entry_price, target_price, stop_loss = r
|
||||||
|
current_price = price_map.get(ts_code)
|
||||||
|
if current_price is None or entry_price is None or entry_price <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pct = round((current_price - entry_price) / entry_price * 100, 2)
|
||||||
|
hit_target = target_price and current_price >= target_price
|
||||||
|
hit_stop = stop_loss and current_price <= stop_loss
|
||||||
|
status = "closed" if (hit_target or hit_stop) else "active"
|
||||||
|
|
||||||
|
# 检查今天是否已经跟踪过
|
||||||
|
exists = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id FROM recommendation_tracking "
|
||||||
|
"WHERE recommendation_id = :rid AND track_date = :td"
|
||||||
|
),
|
||||||
|
{"rid": rec_id, "td": trade_date},
|
||||||
|
)
|
||||||
|
if exists.fetchone():
|
||||||
|
continue
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
tables.recommendation_tracking_table.insert().values(
|
||||||
|
recommendation_id=rec_id,
|
||||||
|
track_date=trade_date,
|
||||||
|
current_price=current_price,
|
||||||
|
pct_from_entry=pct,
|
||||||
|
hit_target=hit_target,
|
||||||
|
hit_stop_loss=hit_stop,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tracked += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
if tracked > 0:
|
||||||
|
logger.info(f"已更新 {tracked} 条推荐跟踪记录")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"更新推荐跟踪失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_performance_stats() -> dict:
|
||||||
|
"""获取推荐胜率统计"""
|
||||||
|
try:
|
||||||
|
from sqlalchemy import text
|
||||||
|
async with get_db() as db:
|
||||||
|
# 总推荐数
|
||||||
|
result = await db.execute(
|
||||||
|
text("SELECT COUNT(DISTINCT id) FROM recommendations")
|
||||||
|
)
|
||||||
|
total = result.scalar() or 0
|
||||||
|
|
||||||
|
# 有跟踪记录的推荐
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(DISTINCT r.id) FROM recommendations r "
|
||||||
|
"INNER JOIN recommendation_tracking t ON t.recommendation_id = r.id"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tracked = result.scalar() or 0
|
||||||
|
|
||||||
|
# 盈利(pct_from_entry > 0)的推荐数
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM ("
|
||||||
|
" SELECT t.recommendation_id, MAX(t.pct_from_entry) as max_pct "
|
||||||
|
" FROM recommendation_tracking t "
|
||||||
|
" GROUP BY t.recommendation_id"
|
||||||
|
") WHERE max_pct > 0"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
winning = result.scalar() or 0
|
||||||
|
|
||||||
|
# 平均收益
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT AVG(latest_pct) FROM ("
|
||||||
|
" SELECT t.recommendation_id, t.pct_from_entry as latest_pct "
|
||||||
|
" FROM recommendation_tracking t "
|
||||||
|
" INNER JOIN ("
|
||||||
|
" SELECT recommendation_id, MAX(track_date) as max_date "
|
||||||
|
" FROM recommendation_tracking GROUP BY recommendation_id"
|
||||||
|
" ) latest ON t.recommendation_id = latest.recommendation_id "
|
||||||
|
" AND t.track_date = latest.max_date"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
avg_return = result.scalar()
|
||||||
|
avg_return = round(float(avg_return), 2) if avg_return else 0
|
||||||
|
|
||||||
|
# 达到目标价的推荐
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(DISTINCT recommendation_id) FROM recommendation_tracking "
|
||||||
|
"WHERE hit_target = 1"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
hit_target_count = result.scalar() or 0
|
||||||
|
|
||||||
|
# 触发止损的推荐
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(DISTINCT recommendation_id) FROM recommendation_tracking "
|
||||||
|
"WHERE hit_stop_loss = 1"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
hit_stop_count = result.scalar() or 0
|
||||||
|
|
||||||
|
# 最近5条有跟踪的推荐详情
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT r.ts_code, r.name, r.signal, r.entry_price, "
|
||||||
|
" r.target_price, r.stop_loss, r.entry_signal_type, r.score, "
|
||||||
|
" t.pct_from_entry, t.current_price, t.track_date, t.hit_target, t.hit_stop_loss, "
|
||||||
|
" r.created_at "
|
||||||
|
"FROM recommendations r "
|
||||||
|
"INNER JOIN recommendation_tracking t ON t.recommendation_id = r.id "
|
||||||
|
"INNER JOIN ("
|
||||||
|
" SELECT recommendation_id, MAX(track_date) as max_date "
|
||||||
|
" FROM recommendation_tracking GROUP BY recommendation_id"
|
||||||
|
") latest ON t.recommendation_id = latest.recommendation_id "
|
||||||
|
" AND t.track_date = latest.max_date "
|
||||||
|
"ORDER BY r.created_at DESC LIMIT 20"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
details = []
|
||||||
|
for row in result.fetchall():
|
||||||
|
r = row._mapping
|
||||||
|
details.append({
|
||||||
|
"ts_code": r["ts_code"],
|
||||||
|
"name": r["name"],
|
||||||
|
"signal": r["signal"],
|
||||||
|
"entry_signal_type": r["entry_signal_type"],
|
||||||
|
"score": r["score"],
|
||||||
|
"entry_price": r["entry_price"],
|
||||||
|
"target_price": r["target_price"],
|
||||||
|
"stop_loss": r["stop_loss"],
|
||||||
|
"current_price": r["current_price"],
|
||||||
|
"pct_from_entry": r["pct_from_entry"],
|
||||||
|
"track_date": r["track_date"],
|
||||||
|
"hit_target": bool(r["hit_target"]),
|
||||||
|
"hit_stop_loss": bool(r["hit_stop_loss"]),
|
||||||
|
"created_at": str(r["created_at"])[:10] if r["created_at"] else "",
|
||||||
|
})
|
||||||
|
|
||||||
|
win_rate = round(winning / tracked * 100, 1) if tracked > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_recommendations": total,
|
||||||
|
"tracked": tracked,
|
||||||
|
"winning": winning,
|
||||||
|
"win_rate": win_rate,
|
||||||
|
"avg_return": avg_return,
|
||||||
|
"hit_target_count": hit_target_count,
|
||||||
|
"hit_stop_count": hit_stop_count,
|
||||||
|
"details": details,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取胜率统计失败: {e}")
|
||||||
|
return {
|
||||||
|
"total_recommendations": 0, "tracked": 0, "winning": 0,
|
||||||
|
"win_rate": 0, "avg_return": 0, "hit_target_count": 0,
|
||||||
|
"hit_stop_count": 0, "details": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_latest_recommendations() -> dict:
|
async def get_latest_recommendations() -> dict:
|
||||||
"""获取最新推荐结果"""
|
"""获取最新推荐结果"""
|
||||||
if _latest_result:
|
if _latest_result:
|
||||||
|
|||||||
@ -35,6 +35,20 @@ async def _run_scan(session_name: str):
|
|||||||
logger.error(f"定时扫描失败 ({session_name}): {e}")
|
logger.error(f"定时扫描失败 ({session_name}): {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_daily_review():
|
||||||
|
"""16:10 自动生成每日复盘报告"""
|
||||||
|
logger.info("=== 开始生成每日复盘报告 ===")
|
||||||
|
try:
|
||||||
|
from app.llm.daily_review import generate_review
|
||||||
|
result = await generate_review()
|
||||||
|
if result.get("status") == "ok":
|
||||||
|
logger.info(f"复盘报告生成成功: {result.get('trade_date')}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"复盘报告生成失败: {result.get('message')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"复盘报告生成异常: {e}")
|
||||||
|
|
||||||
|
|
||||||
def setup_scheduler():
|
def setup_scheduler():
|
||||||
"""配置所有定时任务(交易日时间)"""
|
"""配置所有定时任务(交易日时间)"""
|
||||||
|
|
||||||
@ -85,6 +99,12 @@ def setup_scheduler():
|
|||||||
args=["post_market"], id="post_market", replace_existing=True
|
args=["post_market"], id="post_market", replace_existing=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 每日复盘报告 16:10(扫描完成后生成)
|
||||||
|
scheduler.add_job(
|
||||||
|
_generate_daily_review, CronTrigger(hour=16, minute=10, day_of_week="mon-fri"),
|
||||||
|
id="daily_review", replace_existing=True
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("盘中调度器已配置完成")
|
logger.info("盘中调度器已配置完成")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
117
backend/app/llm/daily_review.py
Normal file
117
backend/app/llm/daily_review.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
"""每日复盘报告生成"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_review() -> dict:
|
||||||
|
"""生成每日复盘报告"""
|
||||||
|
if not settings.deepseek_api_key:
|
||||||
|
return {"status": "error", "message": "未配置 DeepSeek API Key"}
|
||||||
|
|
||||||
|
from app.data.tushare_client import tushare_client
|
||||||
|
from app.data import tencent_client
|
||||||
|
from app.engine.recommender import get_latest_recommendations, get_latest_sectors
|
||||||
|
from app.llm.client import get_client
|
||||||
|
|
||||||
|
trade_date = tushare_client.get_latest_trade_date()
|
||||||
|
|
||||||
|
# 收集市场数据
|
||||||
|
result = await get_latest_recommendations()
|
||||||
|
mt = result.get("market_temp")
|
||||||
|
sectors = await get_latest_sectors()
|
||||||
|
recs = result.get("recommendations", [])
|
||||||
|
|
||||||
|
# 实时指数
|
||||||
|
try:
|
||||||
|
index_data = await tencent_client.get_index_realtime()
|
||||||
|
except Exception:
|
||||||
|
index_data = {}
|
||||||
|
|
||||||
|
# 构建数据摘要
|
||||||
|
market_summary = ""
|
||||||
|
if mt:
|
||||||
|
market_summary = (
|
||||||
|
f"市场温度: {mt.temperature}/100, "
|
||||||
|
f"上涨{mt.up_count}家/下跌{mt.down_count}家, "
|
||||||
|
f"涨停{mt.limit_up_count}家/跌停{mt.limit_down_count}家, "
|
||||||
|
f"连板最高{mt.max_streak}板, 炸板率{mt.broken_rate}%"
|
||||||
|
)
|
||||||
|
|
||||||
|
index_summary = ""
|
||||||
|
name_map = {"000001.SH": "上证", "399001.SZ": "深证", "399006.SZ": "创业板"}
|
||||||
|
for code in ["000001.SH", "399001.SZ", "399006.SZ"]:
|
||||||
|
d = index_data.get(code, {})
|
||||||
|
if d:
|
||||||
|
index_summary += f"{name_map[code]}: {d.get('price', 0):.2f} ({d.get('pct_chg', 0):+.2f}%), "
|
||||||
|
|
||||||
|
sector_summary = "热门板块: " + "、".join(
|
||||||
|
f"{s.sector_name}({s.pct_change:+.1f}%)" for s in sectors[:5]
|
||||||
|
)
|
||||||
|
|
||||||
|
rec_summary = ""
|
||||||
|
for r in recs[:5]:
|
||||||
|
signal_map = {"breakout": "突破", "breakout_confirm": "确认", "pullback": "回踩",
|
||||||
|
"launch": "启动", "reversal": "反转"}
|
||||||
|
st = signal_map.get(r.entry_signal_type, r.entry_signal_type)
|
||||||
|
rec_summary += f"\n- {r.name}({r.ts_code}): {st}型, 评分{r.score}, {r.signal}"
|
||||||
|
|
||||||
|
user_msg = f"""请根据以下数据生成今日A股市场复盘报告(中文):
|
||||||
|
|
||||||
|
日期: {trade_date}
|
||||||
|
{market_summary}
|
||||||
|
{index_summary}
|
||||||
|
{sector_summary}
|
||||||
|
|
||||||
|
今日推荐股票:
|
||||||
|
{rec_summary}
|
||||||
|
|
||||||
|
请按以下格式输出(Markdown格式,总字数300-500字):
|
||||||
|
## 市场概况
|
||||||
|
(指数走势、量能变化)
|
||||||
|
|
||||||
|
## 板块热点
|
||||||
|
(哪些板块领涨、资金流向)
|
||||||
|
|
||||||
|
## 交易机会
|
||||||
|
(今日推荐个股简要点评)
|
||||||
|
|
||||||
|
## 明日关注
|
||||||
|
(关注方向和操作建议)"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
response = await client.chat.completions.create(
|
||||||
|
model=settings.deepseek_model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "你是一位专业的A股市场分析师,擅长市场复盘和策略分析。回复使用Markdown格式,简洁专业。"},
|
||||||
|
{"role": "user", "content": user_msg},
|
||||||
|
],
|
||||||
|
max_tokens=1500,
|
||||||
|
temperature=0.5,
|
||||||
|
)
|
||||||
|
content = response.choices[0].message.content.strip()
|
||||||
|
|
||||||
|
# 保存到数据库
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.db.database import get_db
|
||||||
|
async with get_db() as db:
|
||||||
|
await db.execute(
|
||||||
|
text(
|
||||||
|
"INSERT OR REPLACE INTO daily_reviews (trade_date, content) "
|
||||||
|
"VALUES (:td, :content)"
|
||||||
|
),
|
||||||
|
{"td": trade_date, "content": content},
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(f"已生成 {trade_date} 复盘报告")
|
||||||
|
return {"status": "ok", "trade_date": trade_date, "content": content}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"生成复盘报告失败: {e}")
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.database import init_db
|
from app.db.database import init_db
|
||||||
from app.engine.scheduler import start_scheduler, stop_scheduler
|
from app.engine.scheduler import start_scheduler, stop_scheduler
|
||||||
from app.api import market, sectors, recommendations, stocks, websocket, chat, auth
|
from app.api import market, sectors, recommendations, stocks, websocket, chat, auth, monitor
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.DEBUG if settings.debug else logging.INFO,
|
level=logging.DEBUG if settings.debug else logging.INFO,
|
||||||
@ -80,6 +80,7 @@ app.include_router(recommendations.router)
|
|||||||
app.include_router(stocks.router)
|
app.include_router(stocks.router)
|
||||||
app.include_router(chat.router)
|
app.include_router(chat.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
|
app.include_router(monitor.router)
|
||||||
|
|
||||||
# WebSocket
|
# WebSocket
|
||||||
app.websocket("/ws")(websocket.ws_endpoint)
|
app.websocket("/ws")(websocket.ws_endpoint)
|
||||||
|
|||||||
Binary file not shown.
@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"pages": {
|
"pages": {
|
||||||
"/page": [
|
"/diagnose/page": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
"static/chunks/app/page.js"
|
"static/chunks/app/diagnose/page.js"
|
||||||
],
|
],
|
||||||
"/layout": [
|
"/layout": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
@ -11,20 +11,30 @@
|
|||||||
"static/css/app/layout.css",
|
"static/css/app/layout.css",
|
||||||
"static/chunks/app/layout.js"
|
"static/chunks/app/layout.js"
|
||||||
],
|
],
|
||||||
"/stock/[code]/page": [
|
"/page": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
"static/chunks/app/stock/[code]/page.js"
|
"static/chunks/app/page.js"
|
||||||
|
],
|
||||||
|
"/sectors/page": [
|
||||||
|
"static/chunks/webpack.js",
|
||||||
|
"static/chunks/main-app.js",
|
||||||
|
"static/chunks/app/sectors/page.js"
|
||||||
|
],
|
||||||
|
"/monitor/page": [
|
||||||
|
"static/chunks/webpack.js",
|
||||||
|
"static/chunks/main-app.js",
|
||||||
|
"static/chunks/app/monitor/page.js"
|
||||||
],
|
],
|
||||||
"/recommendations/page": [
|
"/recommendations/page": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
"static/chunks/app/recommendations/page.js"
|
"static/chunks/app/recommendations/page.js"
|
||||||
],
|
],
|
||||||
"/sectors/page": [
|
"/_not-found/page": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
"static/chunks/app/sectors/page.js"
|
"static/chunks/app/_not-found/page.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2
frontend/.next/cache/.tsbuildinfo
vendored
2
frontend/.next/cache/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
@ -1,18 +1,6 @@
|
|||||||
{
|
{
|
||||||
"components/capital-flow.tsx -> echarts": {
|
"app/sectors/page.tsx -> echarts": {
|
||||||
"id": "components/capital-flow.tsx -> echarts",
|
"id": "app/sectors/page.tsx -> echarts",
|
||||||
"files": [
|
|
||||||
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"components/kline-chart.tsx -> echarts": {
|
|
||||||
"id": "components/kline-chart.tsx -> echarts",
|
|
||||||
"files": [
|
|
||||||
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"components/score-radar.tsx -> echarts": {
|
|
||||||
"id": "components/score-radar.tsx -> echarts",
|
|
||||||
"files": [
|
"files": [
|
||||||
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
|
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
"/_not-found/page": "app/_not-found/page.js",
|
||||||
|
"/diagnose/page": "app/diagnose/page.js",
|
||||||
"/page": "app/page.js",
|
"/page": "app/page.js",
|
||||||
"/recommendations/page": "app/recommendations/page.js",
|
"/sectors/page": "app/sectors/page.js",
|
||||||
"/stock/[code]/page": "app/stock/[code]/page.js",
|
"/monitor/page": "app/monitor/page.js",
|
||||||
"/sectors/page": "app/sectors/page.js"
|
"/recommendations/page": "app/recommendations/page.js"
|
||||||
}
|
}
|
||||||
@ -1 +1 @@
|
|||||||
self.__REACT_LOADABLE_MANIFEST="{\"components/capital-flow.tsx -> echarts\":{\"id\":\"components/capital-flow.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/kline-chart.tsx -> echarts\":{\"id\":\"components/kline-chart.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/score-radar.tsx -> echarts\":{\"id\":\"components/score-radar.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}"
|
self.__REACT_LOADABLE_MANIFEST="{\"app/sectors/page.tsx -> echarts\":{\"id\":\"app/sectors/page.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}"
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"node": {},
|
"node": {},
|
||||||
"edge": {},
|
"edge": {},
|
||||||
"encryptionKey": "xV9IIi0vV+SB9UvVKH8lRLbKebnLKLutavDhD7b36pc="
|
"encryptionKey": "2X6fu/eJ5clwy441ELew8wl+I0z3kZMpUonbMY8jvw8="
|
||||||
}
|
}
|
||||||
@ -125,7 +125,7 @@
|
|||||||
/******/
|
/******/
|
||||||
/******/ /* webpack/runtime/getFullHash */
|
/******/ /* webpack/runtime/getFullHash */
|
||||||
/******/ (() => {
|
/******/ (() => {
|
||||||
/******/ __webpack_require__.h = () => ("c0150b0528f1b8db")
|
/******/ __webpack_require__.h = () => ("4eddf011ceda7fdd")
|
||||||
/******/ })();
|
/******/ })();
|
||||||
/******/
|
/******/
|
||||||
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
299
frontend/src/app/diagnose/page.tsx
Normal file
299
frontend/src/app/diagnose/page.tsx
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { fetchAPI, postAPI, type DiagnosisResult } from "@/lib/api";
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
ts_code: string;
|
||||||
|
name: string;
|
||||||
|
industry: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DiagnosePage() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||||
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState<DiagnosisResult | null>(null);
|
||||||
|
const [history, setHistory] = useState<{ ts_code: string; name: string }[]>([]);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close search dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||||
|
setShowSearch(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const searchStock = useCallback(async (keyword: string) => {
|
||||||
|
if (!keyword.trim() || keyword.length < 1) {
|
||||||
|
setSearchResults([]);
|
||||||
|
setShowSearch(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await fetchAPI<SearchResult[]>(`/api/stocks/search?keyword=${encodeURIComponent(keyword)}`);
|
||||||
|
setSearchResults(data);
|
||||||
|
setShowSearch(data.length > 0);
|
||||||
|
} catch {
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputChange = (value: string) => {
|
||||||
|
setInput(value);
|
||||||
|
clearTimeout(searchTimer.current);
|
||||||
|
searchTimer.current = setTimeout(() => searchStock(value), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectStock = (stock: SearchResult) => {
|
||||||
|
setInput(`${stock.name} (${stock.ts_code})`);
|
||||||
|
setShowSearch(false);
|
||||||
|
setSearchResults([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runDiagnosis = async (tsCode?: string) => {
|
||||||
|
let code = tsCode;
|
||||||
|
if (!code) {
|
||||||
|
// Extract ts_code from input like "京投发展 (600683.SH)"
|
||||||
|
const match = input.match(/\((\d{6}\.[A-Z]{2})\)/);
|
||||||
|
if (match) {
|
||||||
|
code = match[1];
|
||||||
|
} else if (/^\d{6}$/.test(input.trim())) {
|
||||||
|
// Try common suffixes
|
||||||
|
code = `${input.trim()}.SH`;
|
||||||
|
} else if (/^\d{6}\.[A-Z]{2}$/.test(input.trim())) {
|
||||||
|
code = input.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const res = await postAPI<DiagnosisResult>(`/api/stocks/${code}/diagnose`);
|
||||||
|
setResult(res);
|
||||||
|
if (res.status === "ok" && res.ts_code) {
|
||||||
|
const name = input.split(" (")[0] || res.ts_code;
|
||||||
|
setHistory((prev) => {
|
||||||
|
const filtered = prev.filter((h) => h.ts_code !== res.ts_code);
|
||||||
|
return [{ ts_code: res.ts_code!, name }, ...filtered].slice(0, 10);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setResult({ status: "error", message: "诊断失败,请检查股票代码后重试" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && !showSearch) {
|
||||||
|
e.preventDefault();
|
||||||
|
runDiagnosis();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 animate-fade-in-up">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-gradient-to-br from-amber-500/25 to-amber-600/15 flex items-center justify-center border border-amber-500/15">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-amber-400">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold tracking-tight">AI 诊断</h1>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
输入任意股票代码,AI 综合技术面、资金面进行全面分析
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<div ref={wrapperRef} className="relative z-30 mb-6 animate-fade-in-up">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => handleInputChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => searchResults.length > 0 && setShowSearch(true)}
|
||||||
|
placeholder="输入股票名称或代码,如 600683 或 京投发展"
|
||||||
|
className="w-full bg-surface-2 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-1 focus:ring-amber-400/30 placeholder-text-muted/40 border border-border-subtle transition-all duration-200"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => runDiagnosis()}
|
||||||
|
disabled={loading || !input.trim()}
|
||||||
|
className="px-6 py-3 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl text-sm font-medium hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-30 transition-all duration-200 border border-amber-500/10 shrink-0"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="w-3.5 h-3.5 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
||||||
|
分析中
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"开始诊断"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showSearch && searchResults.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-bg-secondary border border-border-subtle rounded-xl shadow-lg z-20 overflow-hidden">
|
||||||
|
{searchResults.map((stock) => (
|
||||||
|
<button
|
||||||
|
key={stock.ts_code}
|
||||||
|
onClick={() => selectStock(stock)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2.5 text-sm hover:bg-surface-3 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-text-primary font-medium">{stock.name}</span>
|
||||||
|
<span className="text-text-muted text-xs">{stock.ts_code}</span>
|
||||||
|
</div>
|
||||||
|
{stock.industry && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-2 text-text-muted">
|
||||||
|
{stock.industry}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
{history.length > 0 && !result && (
|
||||||
|
<div className="mb-6 animate-fade-in-up">
|
||||||
|
<div className="text-[10px] text-text-muted/50 mb-2 uppercase tracking-wider">最近诊断</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{history.map((h) => (
|
||||||
|
<button
|
||||||
|
key={h.ts_code}
|
||||||
|
onClick={() => {
|
||||||
|
setInput(`${h.name} (${h.ts_code})`);
|
||||||
|
runDiagnosis(h.ts_code);
|
||||||
|
}}
|
||||||
|
className="text-xs px-3 py-1.5 bg-surface-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-all border border-border-subtle"
|
||||||
|
>
|
||||||
|
{h.name}
|
||||||
|
<span className="text-text-muted/50 ml-1">{h.ts_code}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<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="text-sm text-text-secondary mb-1">正在分析中...</div>
|
||||||
|
<div className="text-xs text-text-muted/50">收集行情数据、技术指标、资金流向并生成分析报告</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Result */}
|
||||||
|
{result && !loading && (
|
||||||
|
<div className="animate-fade-in-up">
|
||||||
|
{result.status === "ok" && result.diagnosis ? (
|
||||||
|
<div className="glass-card-static p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-text-primary">{result.ts_code}</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>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => runDiagnosis(result.ts_code)}
|
||||||
|
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">
|
||||||
|
<path d="M1 4v6h6" />
|
||||||
|
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||||
|
</svg>
|
||||||
|
重新诊断
|
||||||
|
</button>
|
||||||
|
</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" : ""}`}
|
||||||
|
dangerouslySetInnerHTML={{ __html: markdownToHtml(result.diagnosis) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="glass-card-static p-8 text-center">
|
||||||
|
<div className="text-sm text-red-400 mb-2">诊断失败</div>
|
||||||
|
<div className="text-xs text-text-muted">{result.message || "未知错误"}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!result && !loading && history.length === 0 && (
|
||||||
|
<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">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-text-muted/40">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-text-muted mb-2">输入股票代码开始 AI 诊断</div>
|
||||||
|
<div className="text-xs text-text-muted/50 mb-4">
|
||||||
|
支持股票代码(如 600683)或名称(如 京投发展)
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
{["贵州茅台", "宁德时代", "比亚迪"].map((name) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => {
|
||||||
|
setInput(name);
|
||||||
|
searchStock(name);
|
||||||
|
}}
|
||||||
|
className="text-xs px-3 py-1.5 bg-surface-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-all border border-border-subtle"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownToHtml(md: string): string {
|
||||||
|
return md
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.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");
|
||||||
|
}
|
||||||
254
frontend/src/app/monitor/page.tsx
Normal file
254
frontend/src/app/monitor/page.tsx
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { fetchAPI } from "@/lib/api";
|
||||||
|
import type { LimitsData, UnusualStock } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function MonitorPage() {
|
||||||
|
const [tab, setTab] = useState<"limits" | "unusual">("limits");
|
||||||
|
const [limitsData, setLimitsData] = useState<LimitsData | null>(null);
|
||||||
|
const [unusualStocks, setUnusualStocks] = useState<UnusualStock[]>([]);
|
||||||
|
const [tradeDate, setTradeDate] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (tab === "limits") {
|
||||||
|
const data = await fetchAPI<LimitsData>("/api/monitor/limits");
|
||||||
|
setLimitsData(data);
|
||||||
|
setTradeDate(data.trade_date);
|
||||||
|
} else {
|
||||||
|
const data = await fetchAPI<{ trade_date: string; stocks: UnusualStock[] }>("/api/monitor/unusual");
|
||||||
|
setUnusualStocks(data.stocks);
|
||||||
|
setTradeDate(data.trade_date);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("加载监控数据失败:", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-5 animate-fade-in-up">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold tracking-tight">涨跌停 / 异动监控</h1>
|
||||||
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
|
{tradeDate && <span className="font-mono tabular-nums">{tradeDate}</span>}
|
||||||
|
{limitsData?.is_realtime && <span className="text-emerald-400/60 ml-1">· 实时</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 mb-5 animate-fade-in-up delay-75">
|
||||||
|
<button
|
||||||
|
onClick={() => { setTab("limits"); setLoading(true); }}
|
||||||
|
className={`text-xs px-4 py-2 rounded-xl font-medium transition-all ${
|
||||||
|
tab === "limits"
|
||||||
|
? "bg-gradient-to-r from-amber-500/25 to-amber-600/20 text-amber-400 border border-amber-500/15"
|
||||||
|
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
涨跌停
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setTab("unusual"); setLoading(true); }}
|
||||||
|
className={`text-xs px-4 py-2 rounded-xl font-medium transition-all ${
|
||||||
|
tab === "unusual"
|
||||||
|
? "bg-gradient-to-r from-amber-500/25 to-amber-600/20 text-amber-400 border border-amber-500/15"
|
||||||
|
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
异动股
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-16 glass-card-static animate-shimmer" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : tab === "limits" ? (
|
||||||
|
<LimitsView data={limitsData} />
|
||||||
|
) : (
|
||||||
|
<UnusualView stocks={unusualStocks} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LimitsView({ data }: { data: LimitsData | null }) {
|
||||||
|
if (!data) return <div className="glass-card-static p-8 text-center text-text-muted text-sm">暂无数据</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 animate-fade-in-up">
|
||||||
|
{/* Stats bar */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="glass-card-static p-4 flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-red-500/10 flex items-center justify-center">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
|
||||||
|
<path d="M12 2c.5 2.5-.5 5-2 7 1 0 2.5.5 3 2.5.5-2 2-3 3-4-1 3-1 6-4 8.5-1.5 1-3.5 1.5-5 1-1.5-.5-2.5-2-2.5-3.5 0-3 3-5 5-7.5C10 5 11 3.5 12 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold font-mono tabular-nums text-red-400">{data.limit_up.length}</div>
|
||||||
|
<div className="text-[10px] text-text-muted">涨停</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="glass-card-static p-4 flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2">
|
||||||
|
<path d="M12 22c-.5-2.5.5-5 2-7-1 0-2.5-.5-3-2.5-.5 2-2 3-3 4 1-3 1-6 4-8.5 1.5-1 3.5-1.5 5-1 1.5.5 2.5 2 2.5 3.5 0 3-3 5-5 7.5C14 19 13 20.5 12 22z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold font-mono tabular-nums text-emerald-400">{data.limit_down.length}</div>
|
||||||
|
<div className="text-[10px] text-text-muted">跌停</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Limit Up List */}
|
||||||
|
{data.limit_up.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">涨停板</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.limit_up.map((stock, i) => (
|
||||||
|
<LimitStockRow key={stock.ts_code} stock={stock} index={i} type="up" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Limit Down List */}
|
||||||
|
{data.limit_down.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">跌停板</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.limit_down.map((stock, i) => (
|
||||||
|
<LimitStockRow key={stock.ts_code} stock={stock} index={i} type="down" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LimitStockRow({ stock, index, type }: { stock: LimitsData["limit_up"][0]; index: number; type: "up" | "down" }) {
|
||||||
|
const isUp = type === "up";
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`/stock/${stock.ts_code}`}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 glass-card hover:bg-surface-2 transition-colors animate-fade-in-up group"
|
||||||
|
style={{ animationDelay: `${index * 30}ms` }}
|
||||||
|
>
|
||||||
|
<span className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold shrink-0 ${
|
||||||
|
isUp ? "bg-red-500/10 text-red-400" : "bg-emerald-500/10 text-emerald-400"
|
||||||
|
}`}>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-text-primary truncate">{stock.name}</span>
|
||||||
|
<span className="text-[10px] text-text-muted font-mono tabular-nums">{stock.ts_code}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
|
<span className="text-[10px] text-text-muted">
|
||||||
|
封板{stock.limit_times}次
|
||||||
|
</span>
|
||||||
|
{stock.open_times > 0 && (
|
||||||
|
<span className="text-[10px] text-amber-400/80">
|
||||||
|
开板{stock.open_times}次
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{stock.up_stat && (
|
||||||
|
<span className="text-[10px] text-text-muted/60">{stock.up_stat}</span>
|
||||||
|
)}
|
||||||
|
{stock.first_time && (
|
||||||
|
<span className="text-[10px] text-text-muted/60">
|
||||||
|
首封 {stock.first_time}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<div className={`text-sm font-bold font-mono tabular-nums ${isUp ? "text-red-400" : "text-emerald-400"}`}>
|
||||||
|
{stock.close.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<div className={`text-[10px] font-mono tabular-nums ${isUp ? "text-red-400/70" : "text-emerald-400/70"}`}>
|
||||||
|
{stock.pct_chg > 0 ? "+" : ""}{stock.pct_chg.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnusualView({ stocks }: { stocks: UnusualStock[] }) {
|
||||||
|
if (!stocks.length) {
|
||||||
|
return <div className="glass-card-static p-8 text-center text-text-muted text-sm">暂无异动数据</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 animate-fade-in-up">
|
||||||
|
{stocks.map((stock, i) => (
|
||||||
|
<a
|
||||||
|
key={stock.ts_code}
|
||||||
|
href={`/stock/${stock.ts_code}`}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 glass-card hover:bg-surface-2 transition-colors animate-fade-in-up"
|
||||||
|
style={{ animationDelay: `${i * 30}ms` }}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-text-primary truncate">{stock.name}</span>
|
||||||
|
<span className="text-[10px] text-text-muted font-mono tabular-nums">{stock.ts_code}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{stock.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium ${
|
||||||
|
tag === "巨量" ? "bg-amber-500/10 text-amber-400 border border-amber-500/15" :
|
||||||
|
tag === "高振幅" ? "bg-purple-500/10 text-purple-400 border border-purple-500/15" :
|
||||||
|
tag === "急涨" ? "bg-red-500/10 text-red-400 border border-red-500/15" :
|
||||||
|
"bg-emerald-500/10 text-emerald-400 border border-emerald-500/15"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 shrink-0">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[10px] text-text-muted">振幅</div>
|
||||||
|
<div className="text-xs font-mono tabular-nums text-text-secondary">{stock.amplitude.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[10px] text-text-muted">量比</div>
|
||||||
|
<div className="text-xs font-mono tabular-nums text-text-secondary">
|
||||||
|
{stock.volume_ratio > 0 ? stock.volume_ratio.toFixed(1) : "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className={`text-sm font-bold font-mono tabular-nums ${
|
||||||
|
stock.pct_chg > 0 ? "text-red-400" : "text-emerald-400"
|
||||||
|
}`}>
|
||||||
|
{stock.pct_chg > 0 ? "+" : ""}{stock.pct_chg.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-text-muted font-mono tabular-nums">{stock.close.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { fetchAPI, postAPI } from "@/lib/api";
|
import { fetchAPI, postAPI } from "@/lib/api";
|
||||||
import type { LatestResult, SectorData, IndexOverview } from "@/lib/api";
|
import type { LatestResult, SectorData, IndexOverview, DailyReviewResponse } from "@/lib/api";
|
||||||
import MarketTemp from "@/components/market-temp";
|
import MarketTemp from "@/components/market-temp";
|
||||||
import StockCard from "@/components/stock-card";
|
import StockCard from "@/components/stock-card";
|
||||||
import SectorHeatmap from "@/components/sector-heatmap";
|
import SectorHeatmap from "@/components/sector-heatmap";
|
||||||
import { useWebSocket } from "@/hooks/use-websocket";
|
import { useWebSocket } from "@/hooks/use-websocket";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
interface ScanStatus {
|
interface ScanStatus {
|
||||||
is_trading: boolean;
|
is_trading: boolean;
|
||||||
@ -25,21 +26,27 @@ export default function DashboardPage() {
|
|||||||
const [refreshResult, setRefreshResult] = useState<string | null>(null);
|
const [refreshResult, setRefreshResult] = useState<string | null>(null);
|
||||||
const [llmEnabled, setLlmEnabled] = useState(false);
|
const [llmEnabled, setLlmEnabled] = useState(false);
|
||||||
const [indices, setIndices] = useState<IndexOverview[]>([]);
|
const [indices, setIndices] = useState<IndexOverview[]>([]);
|
||||||
|
const [dailyReview, setDailyReview] = useState<string | null>(null);
|
||||||
|
const [generatingReview, setGeneratingReview] = useState(false);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [latest, sectorData, status, health, overview] = await Promise.all([
|
const [latest, sectorData, status, health, overview, reviewData] = await Promise.all([
|
||||||
fetchAPI<LatestResult>("/api/recommendations/latest"),
|
fetchAPI<LatestResult>("/api/recommendations/latest"),
|
||||||
fetchAPI<SectorData[]>("/api/sectors/hot?limit=8"),
|
fetchAPI<SectorData[]>("/api/sectors/hot?limit=8"),
|
||||||
fetchAPI<ScanStatus>("/api/recommendations/status"),
|
fetchAPI<ScanStatus>("/api/recommendations/status"),
|
||||||
fetchAPI<{ llm_enabled: boolean }>("/api/health"),
|
fetchAPI<{ llm_enabled: boolean }>("/api/health"),
|
||||||
fetchAPI<IndexOverview[]>("/api/market/overview").catch(() => []),
|
fetchAPI<IndexOverview[]>("/api/market/overview").catch(() => []),
|
||||||
|
fetchAPI<DailyReviewResponse>("/api/market/daily-review").catch(() => ({ reviews: [] })),
|
||||||
]);
|
]);
|
||||||
setData(latest);
|
setData(latest);
|
||||||
setSectors(sectorData);
|
setSectors(sectorData);
|
||||||
setScanStatus(status);
|
setScanStatus(status);
|
||||||
setLlmEnabled(health.llm_enabled);
|
setLlmEnabled(health.llm_enabled);
|
||||||
setIndices(overview);
|
setIndices(overview);
|
||||||
|
if (reviewData.reviews?.length > 0) {
|
||||||
|
setDailyReview(reviewData.reviews[0].content);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("加载数据失败:", e);
|
console.error("加载数据失败:", e);
|
||||||
} finally {
|
} finally {
|
||||||
@ -116,7 +123,9 @@ export default function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{user?.role === "admin" && (
|
<div className="flex items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
{user?.role === "admin" && (
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
@ -134,6 +143,7 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scan result toast */}
|
{/* Scan result toast */}
|
||||||
@ -150,6 +160,63 @@ export default function DashboardPage() {
|
|||||||
<SectorHeatmap sectors={sectors} />
|
<SectorHeatmap sectors={sectors} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Daily Review */}
|
||||||
|
<div className="animate-fade-in-up delay-100">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
每日复盘
|
||||||
|
</h2>
|
||||||
|
{llmEnabled && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setGeneratingReview(true);
|
||||||
|
setRefreshResult(null);
|
||||||
|
try {
|
||||||
|
const res = await postAPI<{ status: string; content?: string; message?: string }>("/api/market/generate-review");
|
||||||
|
if (res.status === "ok" && res.content) {
|
||||||
|
setDailyReview(res.content);
|
||||||
|
} else if (res.message) {
|
||||||
|
setRefreshResult(res.message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("生成复盘失败:", e);
|
||||||
|
setRefreshResult("生成复盘失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setGeneratingReview(false);
|
||||||
|
setTimeout(() => setRefreshResult(null), 5000);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={generatingReview}
|
||||||
|
className="text-[10px] px-3 py-1.5 bg-surface-2 text-text-secondary rounded-lg hover:bg-surface-4 disabled:opacity-40 transition-all font-medium"
|
||||||
|
>
|
||||||
|
{generatingReview ? (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className="w-2.5 h-2.5 border border-text-muted/40 border-t-text-muted rounded-full animate-spin" />
|
||||||
|
生成中...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
dailyReview ? "重新生成" : "生成复盘"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{dailyReview ? (
|
||||||
|
<div className="glass-card-static p-5">
|
||||||
|
<div
|
||||||
|
className="text-xs text-text-secondary leading-relaxed prose prose-sm prose-invert max-w-none [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-amber-400 [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:first:mt-0 [&_p]:text-text-secondary [&_p]:mb-2 [&_ul]:text-text-secondary [&_li]:mb-1"
|
||||||
|
dangerouslySetInnerHTML={{ __html: markdownToHtml(dailyReview) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="glass-card-static p-6 text-center">
|
||||||
|
<div className="text-text-muted text-sm mb-1">暂无复盘报告</div>
|
||||||
|
<div className="text-text-muted/50 text-xs">
|
||||||
|
{llmEnabled ? "点击「生成复盘」AI自动分析" : "配置LLM后自动生成"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Recommendations */}
|
{/* Recommendations */}
|
||||||
<div className="animate-fade-in-up delay-150">
|
<div className="animate-fade-in-up delay-150">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@ -185,3 +252,23 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markdownToHtml(md: string): string {
|
||||||
|
return md
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { fetchAPI, postAPI } from "@/lib/api";
|
import { fetchAPI, postAPI } from "@/lib/api";
|
||||||
import type { DayGroup } from "@/lib/api";
|
import type { DayGroup, PerformanceStats } from "@/lib/api";
|
||||||
import StockCard from "@/components/stock-card";
|
import StockCard from "@/components/stock-card";
|
||||||
import { useWebSocket } from "@/hooks/use-websocket";
|
import { useWebSocket } from "@/hooks/use-websocket";
|
||||||
|
|
||||||
@ -26,15 +26,18 @@ export default function RecommendationsPage() {
|
|||||||
const [llmEnabled, setLlmEnabled] = useState(false);
|
const [llmEnabled, setLlmEnabled] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [refreshResult, setRefreshResult] = useState<string | null>(null);
|
const [refreshResult, setRefreshResult] = useState<string | null>(null);
|
||||||
|
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [history, health] = await Promise.all([
|
const [history, health, perf] = await Promise.all([
|
||||||
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
||||||
fetchAPI<{ llm_enabled: boolean }>("/api/health"),
|
fetchAPI<{ llm_enabled: boolean }>("/api/health"),
|
||||||
|
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
||||||
]);
|
]);
|
||||||
setDayGroups(history);
|
setDayGroups(history);
|
||||||
setLlmEnabled(health.llm_enabled);
|
setLlmEnabled(health.llm_enabled);
|
||||||
|
setPerformance(perf);
|
||||||
|
|
||||||
// 默认展开最近一天
|
// 默认展开最近一天
|
||||||
setExpandedDays((prev) => {
|
setExpandedDays((prev) => {
|
||||||
@ -138,6 +141,43 @@ export default function RecommendationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Performance Stats */}
|
||||||
|
{performance && performance.total_recommendations > 0 && (
|
||||||
|
<div className="glass-card-static p-4 mb-5 animate-fade-in-up">
|
||||||
|
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">推荐胜率统计</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-surface-1 rounded-lg px-3 py-2.5">
|
||||||
|
<div className="text-[10px] text-text-muted/50 mb-0.5">总推荐</div>
|
||||||
|
<div className="text-lg font-bold font-mono tabular-nums text-text-primary">{performance.total_recommendations}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-1 rounded-lg px-3 py-2.5">
|
||||||
|
<div className="text-[10px] text-text-muted/50 mb-0.5">已跟踪</div>
|
||||||
|
<div className="text-lg font-bold font-mono tabular-nums text-text-secondary">{performance.tracked}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-1 rounded-lg px-3 py-2.5">
|
||||||
|
<div className="text-[10px] text-text-muted/50 mb-0.5">胜率</div>
|
||||||
|
<div className={`text-lg font-bold font-mono tabular-nums ${
|
||||||
|
performance.win_rate >= 60 ? "text-red-400" : performance.win_rate >= 40 ? "text-amber-400" : "text-emerald-400"
|
||||||
|
}`}>
|
||||||
|
{performance.win_rate.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-1 rounded-lg px-3 py-2.5">
|
||||||
|
<div className="text-[10px] text-text-muted/50 mb-0.5">平均收益</div>
|
||||||
|
<div className={`text-lg font-bold font-mono tabular-nums ${
|
||||||
|
performance.avg_return > 0 ? "text-red-400" : "text-emerald-400"
|
||||||
|
}`}>
|
||||||
|
{performance.avg_return > 0 ? "+" : ""}{performance.avg_return.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-3 text-[10px] text-text-muted/60">
|
||||||
|
<span>命中目标 <span className="text-amber-400 font-mono tabular-nums">{performance.hit_target_count}</span></span>
|
||||||
|
<span>触及止损 <span className="text-emerald-400 font-mono tabular-nums">{performance.hit_stop_count}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filter tabs */}
|
{/* Filter tabs */}
|
||||||
<div className="flex gap-1.5 sm:gap-2 mb-4 sm:mb-5 overflow-x-auto pb-2 -mx-4 px-4 sm:mx-0 sm:px-0 animate-fade-in-up delay-75">
|
<div className="flex gap-1.5 sm:gap-2 mb-4 sm:mb-5 overflow-x-auto pb-2 -mx-4 px-4 sm:mx-0 sm:px-0 animate-fade-in-up delay-75">
|
||||||
{[
|
{[
|
||||||
|
|||||||
@ -2,7 +2,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 { SectorData, LeadingStock } 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";
|
||||||
|
|
||||||
@ -184,6 +184,8 @@ function SectorDetailCard({ sector, index }: { sector: SectorData; index: number
|
|||||||
|
|
||||||
export default function SectorsPage() {
|
export default function SectorsPage() {
|
||||||
const [sectors, setSectors] = useState<SectorData[]>([]);
|
const [sectors, setSectors] = useState<SectorData[]>([]);
|
||||||
|
const [showRotation, setShowRotation] = useState(false);
|
||||||
|
const [rotationData, setRotationData] = useState<SectorRotationData | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -206,6 +208,21 @@ export default function SectorsPage() {
|
|||||||
|
|
||||||
const hasRealtime = sectors.some((s) => s.is_realtime);
|
const hasRealtime = sectors.some((s) => s.is_realtime);
|
||||||
|
|
||||||
|
const loadRotation = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchAPI<SectorRotationData>("/api/sectors/rotation?days=5");
|
||||||
|
setRotationData(data);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showRotation && !rotationData) {
|
||||||
|
loadRotation();
|
||||||
|
}
|
||||||
|
}, [showRotation, rotationData, loadRotation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
||||||
<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">
|
||||||
@ -216,8 +233,32 @@ export default function SectorsPage() {
|
|||||||
{hasRealtime && <span className="text-emerald-400/60 ml-1">· 实时数据</span>}
|
{hasRealtime && <span className="text-emerald-400/60 ml-1">· 实时数据</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRotation(!showRotation)}
|
||||||
|
className={`text-xs px-4 py-2 rounded-xl font-medium transition-all ${
|
||||||
|
showRotation
|
||||||
|
? "bg-gradient-to-r from-amber-500/25 to-amber-600/20 text-amber-400 border border-amber-500/15"
|
||||||
|
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{showRotation ? "隐藏轮动" : "板块轮动"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sector Rotation Heatmap */}
|
||||||
|
{showRotation && (
|
||||||
|
<div className="mb-6 animate-fade-in-up">
|
||||||
|
{rotationData && rotationData.sectors.length > 0 ? (
|
||||||
|
<SectorRotationChart data={rotationData} />
|
||||||
|
) : (
|
||||||
|
<div className="glass-card-static p-8 text-center">
|
||||||
|
<div className="w-6 h-6 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-2" />
|
||||||
|
<div className="text-xs text-text-muted">加载轮动数据...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!sectors.length ? (
|
{!sectors.length ? (
|
||||||
<div className="glass-card-static p-12 text-center animate-fade-in-up">
|
<div className="glass-card-static p-12 text-center animate-fade-in-up">
|
||||||
<div className="text-text-muted text-sm mb-1">暂无板块数据</div>
|
<div className="text-text-muted text-sm mb-1">暂无板块数据</div>
|
||||||
@ -233,3 +274,104 @@ export default function SectorsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SectorRotationChart({ data }: { data: SectorRotationData }) {
|
||||||
|
const [el, setEl] = useState<HTMLDivElement | null>(null);
|
||||||
|
const { theme } = useNextTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!el || !data.sectors.length) return;
|
||||||
|
|
||||||
|
let chart: ReturnType<typeof import("echarts")["init"]> | null = null;
|
||||||
|
|
||||||
|
import("echarts").then((ec) => {
|
||||||
|
if (!el) return;
|
||||||
|
const isDark = theme !== "light";
|
||||||
|
chart = ec.init(el, isDark ? "dark" : undefined);
|
||||||
|
|
||||||
|
const isLight = theme === "light";
|
||||||
|
const axisLabelColor = isLight ? "#6b7280" : "#94a3b8";
|
||||||
|
const dates = data.dates.map((d) => d.slice(4));
|
||||||
|
const sectorNames = data.sectors.map((s) => s.sector_name);
|
||||||
|
|
||||||
|
const heatData: [number, number, number][] = [];
|
||||||
|
let minVal = Infinity;
|
||||||
|
let maxVal = -Infinity;
|
||||||
|
data.sectors.forEach((sector, yi) => {
|
||||||
|
dates.forEach((_, xi) => {
|
||||||
|
const dayData = sector.daily_data.find((d) => data.dates[xi] && d.trade_date === data.dates[xi]);
|
||||||
|
const val = dayData?.pct_change ?? 0;
|
||||||
|
heatData.push([xi, yi, val]);
|
||||||
|
if (val < minVal) minVal = val;
|
||||||
|
if (val > maxVal) maxVal = val;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
chart.setOption({
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
tooltip: {
|
||||||
|
formatter: (params: { data: number[] }) => {
|
||||||
|
const [x, y, val] = params.data;
|
||||||
|
return `${sectorNames[y]}<br/>${dates[x]}: <b>${val > 0 ? "+" : ""}${val.toFixed(2)}%</b>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: { left: "15%", right: "5%", top: "5%", bottom: "12%" },
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: dates,
|
||||||
|
splitArea: { show: true },
|
||||||
|
axisLabel: { fontSize: 10, color: axisLabelColor },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: sectorNames,
|
||||||
|
axisLabel: { fontSize: 10, color: axisLabelColor, width: 60, overflow: "truncate" },
|
||||||
|
},
|
||||||
|
visualMap: {
|
||||||
|
min: minVal,
|
||||||
|
max: maxVal,
|
||||||
|
calculable: true,
|
||||||
|
orient: "horizontal",
|
||||||
|
left: "center",
|
||||||
|
bottom: 0,
|
||||||
|
inRange: {
|
||||||
|
color: ["#22c55e", "#fbbf24", "#ef4444"],
|
||||||
|
},
|
||||||
|
textStyle: { fontSize: 10, color: axisLabelColor },
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: "heatmap",
|
||||||
|
data: heatData,
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
fontSize: 9,
|
||||||
|
formatter: (params: { data: number[] }) => {
|
||||||
|
const val = params.data[2];
|
||||||
|
return val > 0 ? `+${val.toFixed(1)}` : val.toFixed(1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleResize = () => chart?.resize();
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { chart?.dispose(); };
|
||||||
|
}, [data, theme, el]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||||
|
近{data.dates.length}日板块轮动
|
||||||
|
</h2>
|
||||||
|
<div ref={setEl} className="w-full" style={{ height: Math.max(data.sectors.length * 28 + 60, 200) }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useNextTheme() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const { useTheme } = require("next-themes");
|
||||||
|
return useTheme();
|
||||||
|
}
|
||||||
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
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, postAPI } from "@/lib/api";
|
||||||
|
import type { DiagnosisResult } 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";
|
||||||
@ -70,6 +71,8 @@ export default function StockDetailPage() {
|
|||||||
// 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[]>([]);
|
||||||
|
const [diagnosis, setDiagnosis] = useState<string | null>(null);
|
||||||
|
const [diagnosing, setDiagnosing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
@ -322,6 +325,54 @@ export default function StockDetailPage() {
|
|||||||
{capitalFlow.length > 0 && <CapitalFlowChart data={capitalFlow} />}
|
{capitalFlow.length > 0 && <CapitalFlowChart data={capitalFlow} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI Diagnosis */}
|
||||||
|
<div className="animate-fade-in-up delay-200">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">AI 诊断</h2>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setDiagnosing(true);
|
||||||
|
setDiagnosis(null);
|
||||||
|
try {
|
||||||
|
const res = await postAPI<DiagnosisResult>(`/api/stocks/${code}/diagnose`);
|
||||||
|
if (res.status === "ok" && res.diagnosis) {
|
||||||
|
setDiagnosis(res.diagnosis);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setDiagnosing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={diagnosing}
|
||||||
|
className="text-xs px-4 py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-40 transition-all duration-200 border border-amber-500/10 font-medium"
|
||||||
|
>
|
||||||
|
{diagnosing ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
||||||
|
诊断中...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"AI 诊断"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{diagnosing && (
|
||||||
|
<div className="glass-card-static p-8 text-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-3" />
|
||||||
|
<div className="text-xs text-text-muted">AI 正在分析 {quote?.name || code} ...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{diagnosis && !diagnosing && (
|
||||||
|
<div className="glass-card-static p-5">
|
||||||
|
<div
|
||||||
|
className="text-xs text-text-secondary leading-relaxed [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-amber-400 [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:first:mt-0 [&_p]:text-text-secondary [&_p]:mb-2 [&_ul]:text-text-secondary [&_li]:mb-1"
|
||||||
|
dangerouslySetInnerHTML={{ __html: markdownToHtml(diagnosis) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Mobile radar */}
|
{/* Mobile radar */}
|
||||||
{signals && (
|
{signals && (
|
||||||
<div className="md:hidden animate-fade-in-up delay-225">
|
<div className="md:hidden animate-fade-in-up delay-225">
|
||||||
@ -489,3 +540,23 @@ function formatFlowAmount(val: number): string {
|
|||||||
if (absVal >= 10000) return (val / 10000).toFixed(1) + "亿";
|
if (absVal >= 10000) return (val / 10000).toFixed(1) + "亿";
|
||||||
return val.toFixed(0) + "万";
|
return val.toFixed(0) + "万";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markdownToHtml(md: string): string {
|
||||||
|
return md
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
|||||||
@ -28,42 +28,103 @@ export default function CapitalFlowChart({ data }: { data: FlowData[] }) {
|
|||||||
const axisLabelColor = isLight ? "#6b7280" : "#94a3b8";
|
const axisLabelColor = isLight ? "#6b7280" : "#94a3b8";
|
||||||
const splitLineColor = isLight ? "#f3f4f6" : "#1e293b";
|
const splitLineColor = isLight ? "#f3f4f6" : "#1e293b";
|
||||||
|
|
||||||
const dates = data.map((d) => d.trade_date);
|
const dates = data.map((d) => d.trade_date.slice(4));
|
||||||
const values = data.map((d) => d.main_net_inflow);
|
const mainValues = data.map((d) => d.main_net_inflow);
|
||||||
|
const totalValues = data.map((d) => d.net_mf_amount);
|
||||||
|
|
||||||
|
// Calculate cumulative main net inflow for trend line
|
||||||
|
const cumulativeMain: number[] = [];
|
||||||
|
let cumSum = 0;
|
||||||
|
for (const v of mainValues) {
|
||||||
|
cumSum += v;
|
||||||
|
cumulativeMain.push(cumSum);
|
||||||
|
}
|
||||||
|
|
||||||
chart.setOption({
|
chart.setOption({
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
trigger: "axis",
|
||||||
formatter: (params: { name: string; value: number }[]) => {
|
formatter: (params: { seriesName: string; name: string; value: number; marker: string }[]) => {
|
||||||
const p = params[0];
|
let result = `${params[0].name}<br/>`;
|
||||||
return `${p.name}<br/>主力净流入: ${p.value.toFixed(0)}万`;
|
for (const p of params) {
|
||||||
|
const val = typeof p.value === "number" ? p.value : 0;
|
||||||
|
const absVal = Math.abs(val);
|
||||||
|
const formatted = absVal >= 10000 ? (val / 10000).toFixed(1) + "亿" : val.toFixed(0) + "万";
|
||||||
|
result += `${p.marker} ${p.seriesName}: ${formatted}<br/>`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
grid: { left: "12%", right: "5%", top: "10%", bottom: "15%" },
|
legend: {
|
||||||
|
data: ["主力净流入", "总净流入", "主力累计"],
|
||||||
|
textStyle: { fontSize: 10, color: axisLabelColor },
|
||||||
|
top: 0,
|
||||||
|
},
|
||||||
|
grid: { left: "12%", right: "8%", top: "15%", bottom: "15%" },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: "category",
|
type: "category",
|
||||||
data: dates,
|
data: dates,
|
||||||
axisLine: { lineStyle: { color: axisLineColor } },
|
axisLine: { lineStyle: { color: axisLineColor } },
|
||||||
axisLabel: { fontSize: 10, color: axisLabelColor, rotate: 30 },
|
axisLabel: { fontSize: 10, color: axisLabelColor, rotate: 30 },
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: [
|
||||||
type: "value",
|
{
|
||||||
splitLine: { lineStyle: { color: splitLineColor } },
|
type: "value",
|
||||||
axisLabel: {
|
splitLine: { lineStyle: { color: splitLineColor } },
|
||||||
fontSize: 10,
|
axisLabel: {
|
||||||
color: axisLabelColor,
|
fontSize: 10,
|
||||||
formatter: (v: number) => (Math.abs(v) >= 10000 ? (v / 10000).toFixed(1) + "亿" : v + "万"),
|
color: axisLabelColor,
|
||||||
|
formatter: (v: number) => (Math.abs(v) >= 10000 ? (v / 10000).toFixed(1) + "亿" : v + "万"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
|
type: "value",
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: axisLabelColor,
|
||||||
|
formatter: (v: number) => (Math.abs(v) >= 10000 ? (v / 10000).toFixed(1) + "亿" : v + "万"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
|
name: "主力净流入",
|
||||||
type: "bar",
|
type: "bar",
|
||||||
data: values.map((v) => ({
|
data: mainValues.map((v) => ({
|
||||||
value: v,
|
value: v,
|
||||||
itemStyle: { color: v > 0 ? "#ef4444" : "#22c55e" },
|
itemStyle: { color: v > 0 ? "#ef4444" : "#22c55e" },
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "总净流入",
|
||||||
|
type: "bar",
|
||||||
|
data: totalValues.map((v) => ({
|
||||||
|
value: v,
|
||||||
|
itemStyle: { color: v > 0 ? "rgba(239,68,68,0.3)" : "rgba(34,197,94,0.3)" },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "主力累计",
|
||||||
|
type: "line",
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: cumulativeMain,
|
||||||
|
smooth: true,
|
||||||
|
symbol: "circle",
|
||||||
|
symbolSize: 4,
|
||||||
|
lineStyle: { color: "#f59e0b", width: 2 },
|
||||||
|
itemStyle: { color: "#f59e0b" },
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: "linear",
|
||||||
|
x: 0, y: 0, x2: 0, y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: "rgba(245,158,11,0.15)" },
|
||||||
|
{ offset: 1, color: "rgba(245,158,11,0)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -79,8 +140,8 @@ export default function CapitalFlowChart({ data }: { data: FlowData[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-bg-card rounded-xl p-4">
|
<div className="bg-bg-card rounded-xl p-4">
|
||||||
<h2 className="text-sm font-medium text-text-secondary mb-2">资金流向</h2>
|
<h2 className="text-sm font-medium text-text-secondary mb-2">资金流向趋势</h2>
|
||||||
<div ref={chartRef} className="w-full h-48" />
|
<div ref={chartRef} className="w-full h-56" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,10 +34,21 @@ function FireIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatIcon() {
|
function MonitorIcon() {
|
||||||
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">
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiagnoseIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" 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>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -79,8 +90,9 @@ export function SidebarNav() {
|
|||||||
<nav className="flex-1 py-5 px-3 space-y-1">
|
<nav className="flex-1 py-5 px-3 space-y-1">
|
||||||
<SideNavItem href="/" icon={<DashboardIcon />} label="总览" />
|
<SideNavItem href="/" icon={<DashboardIcon />} label="总览" />
|
||||||
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐列表" />
|
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐列表" />
|
||||||
|
<SideNavItem href="/monitor" icon={<MonitorIcon />} label="监控" />
|
||||||
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块分析" />
|
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块分析" />
|
||||||
<SideNavItem href="/chat" icon={<ChatIcon />} label="AI 对话" />
|
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="AI 诊断" />
|
||||||
{user?.role === "admin" && (
|
{user?.role === "admin" && (
|
||||||
<SideNavItem href="/users" icon={<UsersIcon />} label="用户管理" />
|
<SideNavItem href="/users" icon={<UsersIcon />} label="用户管理" />
|
||||||
)}
|
)}
|
||||||
@ -115,15 +127,15 @@ export function MobileBottomNav() {
|
|||||||
<MobileNavItem href="/recommendations" label="推荐">
|
<MobileNavItem href="/recommendations" label="推荐">
|
||||||
<TargetIcon />
|
<TargetIcon />
|
||||||
</MobileNavItem>
|
</MobileNavItem>
|
||||||
|
<MobileNavItem href="/monitor" label="监控">
|
||||||
|
<MonitorIcon />
|
||||||
|
</MobileNavItem>
|
||||||
<MobileNavItem href="/sectors" label="板块">
|
<MobileNavItem href="/sectors" label="板块">
|
||||||
<FireIcon />
|
<FireIcon />
|
||||||
</MobileNavItem>
|
</MobileNavItem>
|
||||||
<MobileNavItem href="/chat" label="对话">
|
<MobileNavItem href="/diagnose" label="诊断">
|
||||||
<ChatIcon />
|
<DiagnoseIcon />
|
||||||
</MobileNavItem>
|
</MobileNavItem>
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -158,6 +158,103 @@ export interface DayGroup {
|
|||||||
recommendations: RecommendationData[];
|
recommendations: RecommendationData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Performance Stats ----------
|
||||||
|
|
||||||
|
export interface PerformanceStats {
|
||||||
|
total_recommendations: number;
|
||||||
|
tracked: number;
|
||||||
|
winning: number;
|
||||||
|
win_rate: number;
|
||||||
|
avg_return: number;
|
||||||
|
hit_target_count: number;
|
||||||
|
hit_stop_count: number;
|
||||||
|
details: TrackedRecommendation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackedRecommendation {
|
||||||
|
recommendation_id: number;
|
||||||
|
ts_code: string;
|
||||||
|
name: string;
|
||||||
|
entry_price: number;
|
||||||
|
current_price: number;
|
||||||
|
pct_from_entry: number;
|
||||||
|
hit_target: boolean;
|
||||||
|
hit_stop_loss: boolean;
|
||||||
|
status: string;
|
||||||
|
track_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Monitor ----------
|
||||||
|
|
||||||
|
export interface LimitStock {
|
||||||
|
ts_code: string;
|
||||||
|
name: string;
|
||||||
|
close: number;
|
||||||
|
pct_chg: number;
|
||||||
|
limit_times: number;
|
||||||
|
first_time: string;
|
||||||
|
last_time: string;
|
||||||
|
open_times: number;
|
||||||
|
fd_amount: number;
|
||||||
|
up_stat: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LimitsData {
|
||||||
|
trade_date: string;
|
||||||
|
is_realtime: boolean;
|
||||||
|
limit_up: LimitStock[];
|
||||||
|
limit_down: LimitStock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnusualStock {
|
||||||
|
ts_code: string;
|
||||||
|
name: string;
|
||||||
|
close: number;
|
||||||
|
pct_chg: number;
|
||||||
|
amplitude: number;
|
||||||
|
volume_ratio: number;
|
||||||
|
turnover_rate: number;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnusualData {
|
||||||
|
trade_date: string;
|
||||||
|
stocks: UnusualStock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Daily Review ----------
|
||||||
|
|
||||||
|
export interface DailyReview {
|
||||||
|
trade_date: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyReviewResponse {
|
||||||
|
reviews: DailyReview[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Sector Rotation ----------
|
||||||
|
|
||||||
|
export interface SectorRotationData {
|
||||||
|
trade_date: string;
|
||||||
|
dates: string[];
|
||||||
|
sectors: {
|
||||||
|
sector_code: string;
|
||||||
|
sector_name: string;
|
||||||
|
daily_data: { trade_date: string; pct_change: number; net_amount: number }[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- AI Diagnosis ----------
|
||||||
|
|
||||||
|
export interface DiagnosisResult {
|
||||||
|
status: string;
|
||||||
|
ts_code?: string;
|
||||||
|
diagnosis?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user