This commit is contained in:
aaron 2026-04-15 08:58:21 +08:00
parent 1db602088d
commit 5205fbd8a8
36 changed files with 1894 additions and 113 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("盘中调度器已配置完成")

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@ -1 +1 @@
self.__REACT_LOADABLE_MANIFEST="{\"components/capital-flow.tsx -> echarts\":{\"id\":\"components/capital-flow.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/kline-chart.tsx -> echarts\":{\"id\":\"components/kline-chart.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/score-radar.tsx -> echarts\":{\"id\":\"components/score-radar.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}"
self.__REACT_LOADABLE_MANIFEST="{\"app/sectors/page.tsx -> echarts\":{\"id\":\"app/sectors/page.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}"

View File

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

View File

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

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/^- (.+)$/gm, "<li>$1</li>")
.replace(/(<li>.*<\/li>\n?)+/g, (m) => `<ul>${m}</ul>`)
.replace(/\n{2,}/g, "</p><p>")
.replace(/^(?!<[hulo])/gm, "<p>")
.replace(/(?<![>])$/gm, "</p>")
.replace(/<p><\/p>/g, "")
.replace(/<p>(<h[23]>)/g, "$1")
.replace(/(<\/h[23]>)<\/p>/g, "$1")
.replace(/<p>(<ul>)/g, "$1")
.replace(/(<\/ul>)<\/p>/g, "$1");
}

View File

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

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/^- (.+)$/gm, "<li>$1</li>")
.replace(/(<li>.*<\/li>\n?)+/g, (m) => `<ul>${m}</ul>`)
.replace(/\n{2,}/g, "</p><p>")
.replace(/^(?!<[hulo])/gm, "<p>")
.replace(/(?<![>])$/gm, "</p>")
.replace(/<p><\/p>/g, "")
.replace(/<p>(<h[23]>)/g, "$1")
.replace(/(<\/h[23]>)<\/p>/g, "$1")
.replace(/<p>(<ul>)/g, "$1")
.replace(/(<\/ul>)<\/p>/g, "$1");
}

View File

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

View File

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

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/^- (.+)$/gm, "<li>$1</li>")
.replace(/(<li>.*<\/li>\n?)+/g, (m) => `<ul>${m}</ul>`)
.replace(/\n{2,}/g, "</p><p>")
.replace(/^(?!<[hulo])/gm, "<p>")
.replace(/(?<![>])$/gm, "</p>")
.replace(/<p><\/p>/g, "")
.replace(/<p>(<h[23]>)/g, "$1")
.replace(/(<\/h[23]>)<\/p>/g, "$1")
.replace(/<p>(<ul>)/g, "$1")
.replace(/(<\/ul>)<\/p>/g, "$1");
}

View File

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

View File

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

View File

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