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()
|
||||
|
||||
|
||||
@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():
|
||||
"""盘中:腾讯实时指数行情"""
|
||||
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,
|
||||
get_latest_recommendations,
|
||||
get_recommendation_history,
|
||||
get_performance_stats,
|
||||
)
|
||||
from app.config import is_trading_hours
|
||||
|
||||
@ -91,3 +92,9 @@ async def get_scan_status():
|
||||
async def get_history(days: int = 7):
|
||||
"""获取历史推荐(按日期分组)"""
|
||||
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)
|
||||
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)
|
||||
].head(20)
|
||||
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("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 _update_tracking()
|
||||
|
||||
# 异步 AI 深度分析(不阻塞返回)
|
||||
if settings.deepseek_api_key:
|
||||
import asyncio
|
||||
@ -43,6 +46,210 @@ async def refresh_recommendations(trade_date: str = None, scan_session: str = "m
|
||||
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:
|
||||
"""获取最新推荐结果"""
|
||||
if _latest_result:
|
||||
|
||||
@ -35,6 +35,20 @@ async def _run_scan(session_name: str):
|
||||
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():
|
||||
"""配置所有定时任务(交易日时间)"""
|
||||
|
||||
@ -85,6 +99,12 @@ def setup_scheduler():
|
||||
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("盘中调度器已配置完成")
|
||||
|
||||
|
||||
|
||||
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.db.database import init_db
|
||||
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(
|
||||
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(chat.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(monitor.router)
|
||||
|
||||
# WebSocket
|
||||
app.websocket("/ws")(websocket.ws_endpoint)
|
||||
|
||||
Binary file not shown.
@ -1,9 +1,9 @@
|
||||
{
|
||||
"pages": {
|
||||
"/page": [
|
||||
"/diagnose/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/page.js"
|
||||
"static/chunks/app/diagnose/page.js"
|
||||
],
|
||||
"/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
@ -11,20 +11,30 @@
|
||||
"static/css/app/layout.css",
|
||||
"static/chunks/app/layout.js"
|
||||
],
|
||||
"/stock/[code]/page": [
|
||||
"/page": [
|
||||
"static/chunks/webpack.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": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/recommendations/page.js"
|
||||
],
|
||||
"/sectors/page": [
|
||||
"/_not-found/page": [
|
||||
"static/chunks/webpack.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": {
|
||||
"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",
|
||||
"app/sectors/page.tsx -> echarts": {
|
||||
"id": "app/sectors/page.tsx -> echarts",
|
||||
"files": [
|
||||
"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",
|
||||
"/recommendations/page": "app/recommendations/page.js",
|
||||
"/stock/[code]/page": "app/stock/[code]/page.js",
|
||||
"/sectors/page": "app/sectors/page.js"
|
||||
"/sectors/page": "app/sectors/page.js",
|
||||
"/monitor/page": "app/monitor/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": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "xV9IIi0vV+SB9UvVKH8lRLbKebnLKLutavDhD7b36pc="
|
||||
"encryptionKey": "2X6fu/eJ5clwy441ELew8wl+I0z3kZMpUonbMY8jvw8="
|
||||
}
|
||||
@ -125,7 +125,7 @@
|
||||
/******/
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.h = () => ("c0150b0528f1b8db")
|
||||
/******/ __webpack_require__.h = () => ("4eddf011ceda7fdd")
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* 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 { 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 StockCard from "@/components/stock-card";
|
||||
import SectorHeatmap from "@/components/sector-heatmap";
|
||||
import { useWebSocket } from "@/hooks/use-websocket";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
interface ScanStatus {
|
||||
is_trading: boolean;
|
||||
@ -25,21 +26,27 @@ export default function DashboardPage() {
|
||||
const [refreshResult, setRefreshResult] = useState<string | null>(null);
|
||||
const [llmEnabled, setLlmEnabled] = useState(false);
|
||||
const [indices, setIndices] = useState<IndexOverview[]>([]);
|
||||
const [dailyReview, setDailyReview] = useState<string | null>(null);
|
||||
const [generatingReview, setGeneratingReview] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
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<SectorData[]>("/api/sectors/hot?limit=8"),
|
||||
fetchAPI<ScanStatus>("/api/recommendations/status"),
|
||||
fetchAPI<{ llm_enabled: boolean }>("/api/health"),
|
||||
fetchAPI<IndexOverview[]>("/api/market/overview").catch(() => []),
|
||||
fetchAPI<DailyReviewResponse>("/api/market/daily-review").catch(() => ({ reviews: [] })),
|
||||
]);
|
||||
setData(latest);
|
||||
setSectors(sectorData);
|
||||
setScanStatus(status);
|
||||
setLlmEnabled(health.llm_enabled);
|
||||
setIndices(overview);
|
||||
if (reviewData.reviews?.length > 0) {
|
||||
setDailyReview(reviewData.reviews[0].content);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("加载数据失败:", e);
|
||||
} finally {
|
||||
@ -116,7 +123,9 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{user?.role === "admin" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
{user?.role === "admin" && (
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
@ -134,6 +143,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scan result toast */}
|
||||
@ -150,6 +160,63 @@ export default function DashboardPage() {
|
||||
<SectorHeatmap sectors={sectors} />
|
||||
</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 */}
|
||||
<div className="animate-fade-in-up delay-150">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@ -185,3 +252,23 @@ export default function DashboardPage() {
|
||||
</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 { 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 { useWebSocket } from "@/hooks/use-websocket";
|
||||
|
||||
@ -26,15 +26,18 @@ export default function RecommendationsPage() {
|
||||
const [llmEnabled, setLlmEnabled] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshResult, setRefreshResult] = useState<string | null>(null);
|
||||
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [history, health] = await Promise.all([
|
||||
const [history, health, perf] = await Promise.all([
|
||||
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
||||
fetchAPI<{ llm_enabled: boolean }>("/api/health"),
|
||||
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
||||
]);
|
||||
setDayGroups(history);
|
||||
setLlmEnabled(health.llm_enabled);
|
||||
setPerformance(perf);
|
||||
|
||||
// 默认展开最近一天
|
||||
setExpandedDays((prev) => {
|
||||
@ -138,6 +141,43 @@ export default function RecommendationsPage() {
|
||||
</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 */}
|
||||
<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 { 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 { useWebSocket } from "@/hooks/use-websocket";
|
||||
|
||||
@ -184,6 +184,8 @@ function SectorDetailCard({ sector, index }: { sector: SectorData; index: number
|
||||
|
||||
export default function SectorsPage() {
|
||||
const [sectors, setSectors] = useState<SectorData[]>([]);
|
||||
const [showRotation, setShowRotation] = useState(false);
|
||||
const [rotationData, setRotationData] = useState<SectorRotationData | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
@ -206,6 +208,21 @@ export default function SectorsPage() {
|
||||
|
||||
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 (
|
||||
<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">
|
||||
@ -216,8 +233,32 @@ export default function SectorsPage() {
|
||||
{hasRealtime && <span className="text-emerald-400/60 ml-1">· 实时数据</span>}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{/* 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 ? (
|
||||
<div className="glass-card-static p-12 text-center animate-fade-in-up">
|
||||
<div className="text-text-muted text-sm mb-1">暂无板块数据</div>
|
||||
@ -233,3 +274,104 @@ export default function SectorsPage() {
|
||||
</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 { 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 KlineChart from "@/components/kline-chart";
|
||||
import CapitalFlowChart from "@/components/capital-flow";
|
||||
@ -70,6 +71,8 @@ export default function StockDetailPage() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [kline, setKline] = useState<any[]>([]);
|
||||
const [capitalFlow, setCapitalFlow] = useState<FlowRecord[]>([]);
|
||||
const [diagnosis, setDiagnosis] = useState<string | null>(null);
|
||||
const [diagnosing, setDiagnosing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) return;
|
||||
@ -322,6 +325,54 @@ export default function StockDetailPage() {
|
||||
{capitalFlow.length > 0 && <CapitalFlowChart data={capitalFlow} />}
|
||||
</div>
|
||||
|
||||
{/* AI Diagnosis */}
|
||||
<div className="animate-fade-in-up delay-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">AI 诊断</h2>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setDiagnosing(true);
|
||||
setDiagnosis(null);
|
||||
try {
|
||||
const res = await postAPI<DiagnosisResult>(`/api/stocks/${code}/diagnose`);
|
||||
if (res.status === "ok" && res.diagnosis) {
|
||||
setDiagnosis(res.diagnosis);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setDiagnosing(false);
|
||||
}
|
||||
}}
|
||||
disabled={diagnosing}
|
||||
className="text-xs px-4 py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-40 transition-all duration-200 border border-amber-500/10 font-medium"
|
||||
>
|
||||
{diagnosing ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
||||
诊断中...
|
||||
</span>
|
||||
) : (
|
||||
"AI 诊断"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{diagnosing && (
|
||||
<div className="glass-card-static p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-3" />
|
||||
<div className="text-xs text-text-muted">AI 正在分析 {quote?.name || code} ...</div>
|
||||
</div>
|
||||
)}
|
||||
{diagnosis && !diagnosing && (
|
||||
<div className="glass-card-static p-5">
|
||||
<div
|
||||
className="text-xs text-text-secondary leading-relaxed [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-amber-400 [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:first:mt-0 [&_p]:text-text-secondary [&_p]:mb-2 [&_ul]:text-text-secondary [&_li]:mb-1"
|
||||
dangerouslySetInnerHTML={{ __html: markdownToHtml(diagnosis) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile radar */}
|
||||
{signals && (
|
||||
<div className="md:hidden animate-fade-in-up delay-225">
|
||||
@ -489,3 +540,23 @@ function formatFlowAmount(val: number): string {
|
||||
if (absVal >= 10000) return (val / 10000).toFixed(1) + "亿";
|
||||
return val.toFixed(0) + "万";
|
||||
}
|
||||
|
||||
function markdownToHtml(md: string): string {
|
||||
return md
|
||||
.replace(/&/g, "&")
|
||||
.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 splitLineColor = isLight ? "#f3f4f6" : "#1e293b";
|
||||
|
||||
const dates = data.map((d) => d.trade_date);
|
||||
const values = data.map((d) => d.main_net_inflow);
|
||||
const dates = data.map((d) => d.trade_date.slice(4));
|
||||
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({
|
||||
backgroundColor: "transparent",
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
formatter: (params: { name: string; value: number }[]) => {
|
||||
const p = params[0];
|
||||
return `${p.name}<br/>主力净流入: ${p.value.toFixed(0)}万`;
|
||||
formatter: (params: { seriesName: string; name: string; value: number; marker: string }[]) => {
|
||||
let result = `${params[0].name}<br/>`;
|
||||
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: {
|
||||
type: "category",
|
||||
data: dates,
|
||||
axisLine: { lineStyle: { color: axisLineColor } },
|
||||
axisLabel: { fontSize: 10, color: axisLabelColor, rotate: 30 },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
splitLine: { lineStyle: { color: splitLineColor } },
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
color: axisLabelColor,
|
||||
formatter: (v: number) => (Math.abs(v) >= 10000 ? (v / 10000).toFixed(1) + "亿" : v + "万"),
|
||||
yAxis: [
|
||||
{
|
||||
type: "value",
|
||||
splitLine: { lineStyle: { color: splitLineColor } },
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
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: [
|
||||
{
|
||||
name: "主力净流入",
|
||||
type: "bar",
|
||||
data: values.map((v) => ({
|
||||
data: mainValues.map((v) => ({
|
||||
value: v,
|
||||
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 (
|
||||
<div className="bg-bg-card rounded-xl p-4">
|
||||
<h2 className="text-sm font-medium text-text-secondary mb-2">资金流向</h2>
|
||||
<div ref={chartRef} className="w-full h-48" />
|
||||
<h2 className="text-sm font-medium text-text-secondary mb-2">资金流向趋势</h2>
|
||||
<div ref={chartRef} className="w-full h-56" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -34,10 +34,21 @@ function FireIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function ChatIcon() {
|
||||
function MonitorIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 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>
|
||||
);
|
||||
}
|
||||
@ -79,8 +90,9 @@ export function SidebarNav() {
|
||||
<nav className="flex-1 py-5 px-3 space-y-1">
|
||||
<SideNavItem href="/" icon={<DashboardIcon />} label="总览" />
|
||||
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐列表" />
|
||||
<SideNavItem href="/monitor" icon={<MonitorIcon />} label="监控" />
|
||||
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块分析" />
|
||||
<SideNavItem href="/chat" icon={<ChatIcon />} label="AI 对话" />
|
||||
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="AI 诊断" />
|
||||
{user?.role === "admin" && (
|
||||
<SideNavItem href="/users" icon={<UsersIcon />} label="用户管理" />
|
||||
)}
|
||||
@ -115,15 +127,15 @@ export function MobileBottomNav() {
|
||||
<MobileNavItem href="/recommendations" label="推荐">
|
||||
<TargetIcon />
|
||||
</MobileNavItem>
|
||||
<MobileNavItem href="/monitor" label="监控">
|
||||
<MonitorIcon />
|
||||
</MobileNavItem>
|
||||
<MobileNavItem href="/sectors" label="板块">
|
||||
<FireIcon />
|
||||
</MobileNavItem>
|
||||
<MobileNavItem href="/chat" label="对话">
|
||||
<ChatIcon />
|
||||
<MobileNavItem href="/diagnose" label="诊断">
|
||||
<DiagnoseIcon />
|
||||
</MobileNavItem>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@ -158,6 +158,103 @@ export interface DayGroup {
|
||||
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 {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user