1
This commit is contained in:
parent
5075fcc588
commit
bb402cab00
Binary file not shown.
Binary file not shown.
@ -225,15 +225,16 @@ def scan_hot_sectors(trade_date: str = None) -> list[SectorInfo]:
|
||||
if not sectors:
|
||||
return []
|
||||
|
||||
# ── 板块阶段判定 ──
|
||||
# ── 板块阶段判定(结合连续天数与累计涨幅)──
|
||||
for s in sectors:
|
||||
if s.days_continuous <= 2:
|
||||
cumulative_pct = round(sum(s.pct_trend), 2)
|
||||
if s.days_continuous <= 2 or (s.days_continuous <= 3 and cumulative_pct < 5):
|
||||
s.stage = "early"
|
||||
elif s.days_continuous == 3:
|
||||
elif s.days_continuous == 3 and cumulative_pct >= 5 or (s.days_continuous == 4 and cumulative_pct < 8):
|
||||
s.stage = "mid"
|
||||
elif s.days_continuous == 4:
|
||||
elif (s.days_continuous == 4 and cumulative_pct >= 8) or (s.days_continuous == 5 and cumulative_pct < 10):
|
||||
s.stage = "late"
|
||||
else:
|
||||
elif (s.days_continuous >= 5 and cumulative_pct >= 10) or s.days_continuous >= 6:
|
||||
s.stage = "end"
|
||||
|
||||
# ── 综合评分 ──
|
||||
|
||||
@ -254,20 +254,24 @@ def generate_signals(ts_code: str, name: str = "") -> TechnicalSignal:
|
||||
# 计算分数
|
||||
score = 0
|
||||
signal_count = 0
|
||||
signals = {
|
||||
ma_bullish: 15,
|
||||
volume_breakout: 20,
|
||||
macd_golden: 15,
|
||||
rsi_healthy: 10,
|
||||
pullback_support: 15,
|
||||
big_yang: 15,
|
||||
boll_support: 10,
|
||||
}
|
||||
for is_true, points in signals.items():
|
||||
signal_map = [
|
||||
(ma_bullish, 15),
|
||||
(volume_breakout, 20),
|
||||
(macd_golden, 15),
|
||||
(rsi_healthy, 10),
|
||||
(pullback_support, 15),
|
||||
(big_yang, 15),
|
||||
(boll_support, 10),
|
||||
]
|
||||
for is_true, points in signal_map:
|
||||
if is_true:
|
||||
score += points
|
||||
signal_count += 1
|
||||
|
||||
# 趋势评分(与推荐体系一致)
|
||||
from app.engine.screener import _score_trend
|
||||
trend_score = round(_score_trend(df), 1)
|
||||
|
||||
# 支撑压力位
|
||||
support, resist = _calc_support_resist(df)
|
||||
last_close = float(df.iloc[-1]["close"])
|
||||
@ -287,6 +291,7 @@ def generate_signals(ts_code: str, name: str = "") -> TechnicalSignal:
|
||||
big_yang=big_yang,
|
||||
boll_support=boll_support,
|
||||
score=score,
|
||||
trend_score=trend_score,
|
||||
signal_count=signal_count,
|
||||
rally_pct_5d=rally_5d,
|
||||
rally_pct_10d=rally_10d,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,4 +1,8 @@
|
||||
"""涨跌停/异动监控 API"""
|
||||
"""涨跌停/异动监控 API
|
||||
|
||||
盘后:使用 Tushare 涨跌停列表和日级数据(完整准确)
|
||||
盘中:涨跌停仍用 Tushare,异动股用腾讯实时行情(量比/振幅/急涨急跌)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
@ -12,10 +16,12 @@ router = APIRouter(prefix="/api/monitor", tags=["monitor"])
|
||||
async def get_limits():
|
||||
"""获取涨跌停数据"""
|
||||
trade_date = tushare_client.get_latest_trade_date()
|
||||
is_realtime = is_market_session()
|
||||
|
||||
limit_df = tushare_client.get_limit_list(trade_date)
|
||||
|
||||
if limit_df.empty:
|
||||
return {"trade_date": trade_date, "limit_up": [], "limit_down": []}
|
||||
return {"trade_date": trade_date, "is_realtime": False, "limit_up": [], "limit_down": []}
|
||||
|
||||
# 拆分涨停和跌停
|
||||
up_df = limit_df[limit_df["limit"] == "U"].sort_values("pct_chg", ascending=False)
|
||||
@ -40,7 +46,7 @@ async def get_limits():
|
||||
|
||||
return {
|
||||
"trade_date": trade_date,
|
||||
"is_realtime": is_market_session(),
|
||||
"is_realtime": is_realtime,
|
||||
"limit_up": _parse(up_df),
|
||||
"limit_down": _parse(down_df),
|
||||
}
|
||||
@ -48,13 +54,22 @@ async def get_limits():
|
||||
|
||||
@router.get("/unusual")
|
||||
async def get_unusual():
|
||||
"""获取异动股(量比>3、振幅>8%、快速拉升)"""
|
||||
trade_date = tushare_client.get_latest_trade_date()
|
||||
"""获取异动股(量比>3、振幅>8%、快速拉升)
|
||||
|
||||
# 获取全市场日线
|
||||
盘中时使用腾讯实时行情补充量比和涨跌幅,
|
||||
盘后使用 Tushare 日级数据。
|
||||
"""
|
||||
trade_date = tushare_client.get_latest_trade_date()
|
||||
is_realtime = is_market_session()
|
||||
|
||||
if is_realtime:
|
||||
# 盘中:用腾讯实时行情扫描异动
|
||||
return await _get_unusual_realtime(trade_date)
|
||||
|
||||
# 盘后:使用 Tushare 日级数据
|
||||
daily = tushare_client.get_daily_all(trade_date)
|
||||
if daily.empty:
|
||||
return {"trade_date": trade_date, "stocks": []}
|
||||
return {"trade_date": trade_date, "is_realtime": False, "stocks": []}
|
||||
|
||||
basic = tushare_client.get_daily_basic(trade_date)
|
||||
if not basic.empty:
|
||||
@ -66,7 +81,6 @@ async def get_unusual():
|
||||
for _, r in stock_basic.iterrows():
|
||||
name_map[r["ts_code"]] = r["name"]
|
||||
|
||||
# 筛选异动条件
|
||||
unusual = []
|
||||
for _, r in daily.iterrows():
|
||||
ts = r.get("ts_code", "")
|
||||
@ -103,4 +117,55 @@ async def get_unusual():
|
||||
})
|
||||
|
||||
unusual.sort(key=lambda x: abs(x["pct_chg"]), reverse=True)
|
||||
return {"trade_date": trade_date, "stocks": unusual[:50]}
|
||||
return {"trade_date": trade_date, "is_realtime": False, "stocks": unusual[:50]}
|
||||
|
||||
|
||||
async def _get_unusual_realtime(trade_date: str) -> dict:
|
||||
"""盘中:用腾讯实时行情扫描异动"""
|
||||
from app.data.tencent_client import get_realtime_quotes_batch
|
||||
|
||||
stock_basic = tushare_client.get_stock_basic()
|
||||
if stock_basic.empty:
|
||||
return {"trade_date": trade_date, "is_realtime": True, "stocks": []}
|
||||
|
||||
# 只扫描主板(非 ST)
|
||||
valid = stock_basic[
|
||||
~stock_basic["name"].str.contains("ST", na=False)
|
||||
]
|
||||
codes = valid["ts_code"].tolist()
|
||||
|
||||
# 分批获取实时行情
|
||||
unusual = []
|
||||
batch_size = 200
|
||||
for i in range(0, len(codes), batch_size):
|
||||
batch = codes[i:i + batch_size]
|
||||
quotes = await get_realtime_quotes_batch(batch)
|
||||
|
||||
for ts_code, q in quotes.items():
|
||||
if not q.price or q.price <= 0:
|
||||
continue
|
||||
|
||||
tags = []
|
||||
if q.volume_ratio and q.volume_ratio > 3:
|
||||
tags.append("巨量")
|
||||
if q.amplitude and q.amplitude > 8:
|
||||
tags.append("高振幅")
|
||||
if q.pct_chg > 7:
|
||||
tags.append("急涨")
|
||||
elif q.pct_chg < -7:
|
||||
tags.append("急跌")
|
||||
|
||||
if tags:
|
||||
unusual.append({
|
||||
"ts_code": ts_code,
|
||||
"name": q.name or ts_code,
|
||||
"close": q.price,
|
||||
"pct_chg": q.pct_chg,
|
||||
"amplitude": round(q.amplitude, 2) if q.amplitude else 0,
|
||||
"volume_ratio": round(q.volume_ratio, 2) if q.volume_ratio else 0,
|
||||
"turnover_rate": round(q.turnover_rate, 2) if q.turnover_rate else 0,
|
||||
"tags": tags,
|
||||
})
|
||||
|
||||
unusual.sort(key=lambda x: abs(x["pct_chg"]), reverse=True)
|
||||
return {"trade_date": trade_date, "is_realtime": True, "stocks": unusual[:50]}
|
||||
@ -43,6 +43,8 @@ async def get_latest():
|
||||
"sector_score": r.sector_score,
|
||||
"capital_score": r.capital_score,
|
||||
"technical_score": r.technical_score,
|
||||
"supply_demand_score": r.supply_demand_score,
|
||||
"price_action_score": r.price_action_score,
|
||||
"position_score": r.position_score,
|
||||
"valuation_score": r.valuation_score,
|
||||
"entry_price": r.entry_price,
|
||||
@ -79,6 +81,20 @@ async def refresh(scan_session: str = "manual"):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/update-tracking")
|
||||
async def update_tracking():
|
||||
"""独立更新推荐跟踪数据(不触发新扫描,盘中可单独调用)"""
|
||||
from app.engine.recommender import _update_tracking
|
||||
await _update_tracking()
|
||||
stats = await get_performance_stats()
|
||||
return {
|
||||
"status": "ok",
|
||||
"tracked": stats.get("tracked", 0),
|
||||
"win_rate": stats.get("win_rate", 0),
|
||||
"avg_return": stats.get("avg_return", 0),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_scan_status():
|
||||
"""获取当前扫描状态信息"""
|
||||
|
||||
@ -7,6 +7,7 @@ from fastapi import APIRouter
|
||||
from app.config import is_market_session
|
||||
from app.data.tushare_client import tushare_client
|
||||
from app.data.tencent_client import get_realtime_quotes_batch
|
||||
from app.data.cache import cache
|
||||
from app.engine.recommender import get_latest_sectors
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -122,6 +123,12 @@ async def get_hot_sectors(limit: int = 10):
|
||||
@router.get("/rotation")
|
||||
async def get_sector_rotation(days: int = 5):
|
||||
"""获取近N日板块轮动数据(用于热力图)"""
|
||||
# 检查缓存
|
||||
cache_key = f"sector_rotation:{days}"
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
trade_date = tushare_client.get_latest_trade_date()
|
||||
|
||||
# 获取交易日历
|
||||
@ -190,4 +197,9 @@ async def get_sector_rotation(days: int = 5):
|
||||
reverse=True,
|
||||
)[:20]
|
||||
|
||||
return {"trade_date": trade_date, "dates": recent_dates, "sectors": sorted_sectors}
|
||||
result = {"trade_date": trade_date, "dates": recent_dates, "sectors": sorted_sectors}
|
||||
|
||||
# 写入缓存,TTL 300秒(5分钟)
|
||||
cache.set(cache_key, result, ttl=300)
|
||||
|
||||
return result
|
||||
|
||||
@ -1,12 +1,20 @@
|
||||
"""个股分析 API"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from app.data.tushare_client import tushare_client
|
||||
from app.data import tencent_client
|
||||
from app.analysis.technical import add_all_indicators
|
||||
from app.analysis.signals import generate_signals
|
||||
from app.db.database import get_db
|
||||
from app.db import tables
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/stocks", tags=["stocks"])
|
||||
|
||||
|
||||
@ -91,35 +99,115 @@ async def search_stock(keyword: str):
|
||||
return matches[["ts_code", "name", "industry"]].to_dict(orient="records")
|
||||
|
||||
|
||||
@router.get("/{ts_code}/diagnose/history")
|
||||
async def get_diagnose_history(ts_code: str):
|
||||
"""获取个股最近5次诊断历史"""
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, ts_code, name, diagnosis, created_at "
|
||||
"FROM stock_diagnoses "
|
||||
"WHERE ts_code = :code "
|
||||
"ORDER BY created_at DESC LIMIT 5"
|
||||
),
|
||||
{"code": ts_code},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
history = []
|
||||
for row in rows:
|
||||
r = row._mapping
|
||||
history.append({
|
||||
"id": r["id"],
|
||||
"ts_code": r["ts_code"],
|
||||
"name": r["name"],
|
||||
"diagnosis": r["diagnosis"],
|
||||
"created_at": str(r["created_at"]),
|
||||
})
|
||||
return history
|
||||
except Exception as e:
|
||||
logger.error(f"获取诊断历史失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/{ts_code}/diagnose")
|
||||
async def diagnose_stock(ts_code: str):
|
||||
"""AI 诊断个股"""
|
||||
"""AI 诊断个股(SSE 流式返回)"""
|
||||
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
|
||||
from sqlalchemy import text
|
||||
|
||||
# 收集数据
|
||||
# ── 检查是否有最近30分钟内的诊断记录,若有则直接返回 ──
|
||||
try:
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, ts_code, name, diagnosis, created_at "
|
||||
"FROM stock_diagnoses "
|
||||
"WHERE ts_code = :code "
|
||||
"AND created_at >= datetime('now', '-30 minutes', 'localtime') "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
),
|
||||
{"code": ts_code},
|
||||
)
|
||||
recent_row = result.fetchone()
|
||||
if recent_row:
|
||||
r = recent_row._mapping
|
||||
# 直接返回缓存结果
|
||||
async def _cached_stream():
|
||||
yield f"data: {json.dumps({'cached': True, 'diagnosis': r['diagnosis']}, ensure_ascii=False)}\n\n"
|
||||
yield f"data: {json.dumps({'done': True, 'ts_code': ts_code})}\n\n"
|
||||
return StreamingResponse(_cached_stream(), media_type="text/event-stream")
|
||||
except Exception as e:
|
||||
logger.warning(f"检查诊断缓存失败: {e}")
|
||||
|
||||
# ── 收集数据 ──
|
||||
quote = await tencent_client.get_realtime_quote(ts_code)
|
||||
signals = generate_signals(ts_code)
|
||||
|
||||
df_daily = tushare_client.get_stock_daily(ts_code, days=30)
|
||||
df_daily = tushare_client.get_stock_daily(ts_code, days=120)
|
||||
df_flow = tushare_client.get_stock_moneyflow(ts_code, days=10)
|
||||
|
||||
# ── 数据新鲜度检查 ──
|
||||
freshness_note = ""
|
||||
data_stale = False
|
||||
current_price_source = ""
|
||||
|
||||
if not df_daily.empty:
|
||||
df_daily = df_daily.sort_values("trade_date")
|
||||
latest_kline_date = str(df_daily.iloc[-1]["trade_date"])
|
||||
# 检查 K 线数据是否超过 10 天未更新
|
||||
cutoff_date = (datetime.now() - timedelta(days=10)).strftime("%Y%m%d")
|
||||
if latest_kline_date < cutoff_date:
|
||||
logger.warning(f"K线数据过时 {ts_code}: 最新={latest_kline_date}, 10天前阈值={cutoff_date}")
|
||||
data_stale = True
|
||||
|
||||
# 如果最新 K 线日期不是今天,添加新鲜度提示
|
||||
today_str = datetime.now().strftime("%Y%m%d")
|
||||
if latest_kline_date != today_str:
|
||||
freshness_note = f"\n\n注意:K线数据最新日期为{latest_kline_date},非当日数据,部分分析可能滞后。"
|
||||
|
||||
# 数据过时时,使用实时报价价格作为"当前价"替代
|
||||
if data_stale and quote and quote.price > 0:
|
||||
current_price_source = f"(实时报价价 {quote.price},K线收盘价 {df_daily.iloc[-1]['close']} 可能滞后)"
|
||||
|
||||
# 构建数据摘要
|
||||
quote_str = ""
|
||||
if quote:
|
||||
quote_str = (
|
||||
f"当前价: {quote.price}, 涨跌幅: {quote.pct_chg}%, "
|
||||
f"当前价: {quote.price}{current_price_source}, 涨跌幅: {quote.pct_chg}%, "
|
||||
f"换手率: {quote.turnover_rate}%, 量比: {quote.volume_ratio}, "
|
||||
f"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.trend_score}/100(均线排列+高低点结构+MA20方向,主评分10%权重), "
|
||||
f"辅助信号计数={signals.signal_count}/7(触发计分,仅供参考不参与主评分), "
|
||||
f"均线多头: {signals.ma_bullish}, "
|
||||
f"放量突破: {signals.volume_breakout}, "
|
||||
f"MACD金叉: {signals.macd_golden}, "
|
||||
@ -142,7 +230,6 @@ async def diagnose_stock(ts_code: str):
|
||||
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
|
||||
@ -171,6 +258,7 @@ async def diagnose_stock(ts_code: str):
|
||||
flow_str = ""
|
||||
if not df_flow.empty:
|
||||
df_flow = df_flow.sort_values("trade_date")
|
||||
latest_flow_date = str(df_flow.iloc[-1]["trade_date"])
|
||||
recent_3 = df_flow.tail(3)
|
||||
total_main = 0
|
||||
for _, r in recent_3.iterrows():
|
||||
@ -180,15 +268,78 @@ async def diagnose_stock(ts_code: str):
|
||||
)
|
||||
total_main += main_net
|
||||
flow_str = f"近3日主力净流入: {total_main:.0f}万"
|
||||
# 资金流向数据新鲜度标注
|
||||
today_str = datetime.now().strftime("%Y%m%d")
|
||||
if latest_flow_date != today_str:
|
||||
flow_str += f"(数据截至{latest_flow_date},盘中可能滞后一日)"
|
||||
|
||||
# 基本信息
|
||||
basic_info = ""
|
||||
stock_name = ""
|
||||
industry = ""
|
||||
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', '未知')}"
|
||||
stock_name = r["name"]
|
||||
industry = r.get("industry", "") or ""
|
||||
basic_info = f"名称: {r['name']}, 行业: {industry}"
|
||||
|
||||
# 推荐体系评分(如果该股票在推荐列表中)
|
||||
rec_score_str = ""
|
||||
try:
|
||||
async with get_db() as db:
|
||||
rec_result = await db.execute(
|
||||
text(
|
||||
"SELECT score, supply_demand_score, price_action_score, "
|
||||
"technical_score, position_score, sector, signal "
|
||||
"FROM recommendations "
|
||||
"WHERE ts_code = :code AND score >= 60 "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
),
|
||||
{"code": ts_code},
|
||||
)
|
||||
rec_row = rec_result.fetchone()
|
||||
if rec_row:
|
||||
rm = rec_row._mapping
|
||||
rec_score_str = (
|
||||
f"\n推荐体系评分: 综合={rm['score']}, "
|
||||
f"供需={rm['supply_demand_score']}(50%权重), "
|
||||
f"形态={rm['price_action_score']}(40%权重), "
|
||||
f"趋势={rm['technical_score']}(10%权重), "
|
||||
f"位置安全={rm['position_score']}, "
|
||||
f"板块={rm['sector']}, "
|
||||
f"信号={rm['signal']}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 板块热度(如果有该行业板块数据)
|
||||
sector_str = ""
|
||||
if industry:
|
||||
try:
|
||||
async with get_db() as db:
|
||||
sector_result = await db.execute(
|
||||
text(
|
||||
"SELECT sector_name, pct_change, heat_score, stage, "
|
||||
"days_continuous, limit_up_count "
|
||||
"FROM sector_heat "
|
||||
"WHERE sector_name LIKE :industry "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
),
|
||||
{"industry": f"%{industry}%"},
|
||||
)
|
||||
s_row = sector_result.fetchone()
|
||||
if s_row:
|
||||
sm = s_row._mapping
|
||||
sector_str = (
|
||||
f"板块热度: {sm['sector_name']} 涨幅={sm['pct_change']}%, "
|
||||
f"热度={sm['heat_score']}, 阶段={sm['stage']}, "
|
||||
f"连续{sm['days_continuous']}天, 涨停数={sm['limit_up_count']}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
user_msg = f"""请对以下A股进行全面诊断分析:
|
||||
|
||||
@ -202,34 +353,73 @@ async def diagnose_stock(ts_code: str):
|
||||
趋势: {trend_str}
|
||||
{ma_info}
|
||||
资金面: {flow_str}
|
||||
{rec_score_str}
|
||||
{sector_str}
|
||||
|
||||
重要提示:技术评分基于7项信号触发计分,分数低不代表股票差,可能处于蓄势阶段。位置安全评分高(>80)表示股价处于相对低位。请综合技术评分和位置安全评分一起判断。
|
||||
重要提示:
|
||||
1. 趋势评分是推荐体系的技术面核心分数(均线排列40+高低点结构35+MA20方向25=满分100),辅助信号计数仅供参考不参与主评分。
|
||||
2. 位置安全评分高(>80)表示股价处于相对低位,低(<40)表示可能追高。
|
||||
3. 如果有推荐体系评分,请作为主要分析依据;趋势评分和信号计数从不同维度描述技术面状态。
|
||||
{freshness_note}
|
||||
|
||||
请从以下维度分析(Markdown格式,简洁专业):
|
||||
## 综合评级
|
||||
(给出1-5星评级和一句话总结,综合技术面和位置安全评分)
|
||||
(给出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)}
|
||||
# ── SSE 流式返回 ──
|
||||
async def _stream_diagnosis():
|
||||
full_content = ""
|
||||
try:
|
||||
client = get_client()
|
||||
stream = 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,
|
||||
stream=True,
|
||||
)
|
||||
async for chunk in stream:
|
||||
if chunk.choices and chunk.choices[0].delta:
|
||||
token = chunk.choices[0].delta.content or ""
|
||||
if token:
|
||||
full_content += token
|
||||
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 流式完成后,保存到数据库
|
||||
full_content = full_content.strip()
|
||||
if full_content:
|
||||
try:
|
||||
async with get_db() as db:
|
||||
await db.execute(
|
||||
tables.stock_diagnoses_table.insert().values(
|
||||
ts_code=ts_code,
|
||||
name=stock_name or ts_code,
|
||||
diagnosis=full_content,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(f"已保存诊断结果到数据库: {ts_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"保存诊断结果到数据库失败: {e}")
|
||||
|
||||
yield f"data: {json.dumps({'done': True, 'ts_code': ts_code}, ensure_ascii=False)}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error(f"诊断流式调用失败: {error_msg}")
|
||||
yield f"data: {json.dumps({'error': error_msg}, ensure_ascii=False)}\n\n"
|
||||
yield f"data: {json.dumps({'done': True, 'ts_code': ts_code}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(_stream_diagnosis(), media_type="text/event-stream")
|
||||
Binary file not shown.
@ -94,7 +94,8 @@ class TechnicalSignal(BaseModel):
|
||||
pullback_support: bool = False # 缩量回踩支撑
|
||||
big_yang: bool = False # 底部放量长阳
|
||||
boll_support: bool = False # 布林带下轨支撑
|
||||
score: float = 0 # 技术面总分
|
||||
score: float = 0 # 信号触发计数分
|
||||
trend_score: float = 0 # 趋势评分(推荐体系用的技术面分数)
|
||||
signal_count: int = 0 # 满足的信号数量
|
||||
|
||||
# 位置安全评估(防追高)
|
||||
@ -118,6 +119,8 @@ class Recommendation(BaseModel):
|
||||
sector_score: float
|
||||
capital_score: float
|
||||
technical_score: float
|
||||
supply_demand_score: float = 0 # 供需评分(主评分50%权重)
|
||||
price_action_score: float = 0 # 价格行为评分(主评分40%权重)
|
||||
position_score: float = 50 # 位置安全得分
|
||||
valuation_score: float = 50 # 估值安全得分
|
||||
signal: str # BUY / SELL / HOLD
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -34,6 +34,13 @@ async def init_db():
|
||||
"ALTER TABLE market_temperature ADD COLUMN max_streak INTEGER",
|
||||
"ALTER TABLE market_temperature ADD COLUMN broken_rate REAL",
|
||||
"ALTER TABLE recommendations ADD COLUMN entry_signal_type TEXT DEFAULT 'none'",
|
||||
"ALTER TABLE sector_heat ADD COLUMN stage TEXT",
|
||||
"ALTER TABLE sector_heat ADD COLUMN days_continuous INTEGER",
|
||||
"ALTER TABLE sector_heat ADD COLUMN member_count INTEGER",
|
||||
"ALTER TABLE sector_heat ADD COLUMN leading_stocks TEXT",
|
||||
"ALTER TABLE sector_heat ADD COLUMN pct_trend TEXT",
|
||||
"ALTER TABLE sector_heat ADD COLUMN turnover_avg REAL",
|
||||
"ALTER TABLE sector_heat ADD COLUMN main_force_ratio REAL",
|
||||
]:
|
||||
try:
|
||||
await conn.execute(
|
||||
|
||||
@ -18,6 +18,8 @@ recommendations_table = Table(
|
||||
Column("sector_score", Float),
|
||||
Column("capital_score", Float),
|
||||
Column("technical_score", Float),
|
||||
Column("supply_demand_score", Float, default=0),
|
||||
Column("price_action_score", Float, default=0),
|
||||
Column("position_score", Float),
|
||||
Column("valuation_score", Float),
|
||||
Column("signal", Text),
|
||||
@ -42,6 +44,13 @@ sector_heat_table = Table(
|
||||
Column("capital_inflow", Float),
|
||||
Column("limit_up_count", Integer),
|
||||
Column("heat_score", Float),
|
||||
Column("stage", Text),
|
||||
Column("days_continuous", Integer),
|
||||
Column("member_count", Integer),
|
||||
Column("leading_stocks", Text), # JSON string
|
||||
Column("pct_trend", Text), # JSON string
|
||||
Column("turnover_avg", Float),
|
||||
Column("main_force_ratio", Float),
|
||||
Column("trade_date", Text, nullable=False),
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
)
|
||||
@ -91,3 +100,12 @@ daily_reviews_table = Table(
|
||||
Column("content", Text, default=""),
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
)
|
||||
|
||||
stock_diagnoses_table = Table(
|
||||
"stock_diagnoses", metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("ts_code", Text, nullable=False),
|
||||
Column("name", Text, nullable=False),
|
||||
Column("diagnosis", Text, nullable=False),
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -5,6 +5,7 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime
|
||||
from app.engine.screener import run_screening
|
||||
from app.data.models import Recommendation, MarketTemperature, SectorInfo
|
||||
@ -138,29 +139,31 @@ async def get_performance_stats() -> dict:
|
||||
)
|
||||
tracked = result.scalar() or 0
|
||||
|
||||
# 盈利(pct_from_entry > 0)的推荐数
|
||||
# 胜率基于最新跟踪日的最终 pct(正值=盈利,负值=亏损)
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM ("
|
||||
" SELECT t.recommendation_id, MAX(t.pct_from_entry) as max_pct "
|
||||
" SELECT t.recommendation_id, t.pct_from_entry as latest_pct "
|
||||
" FROM recommendation_tracking t "
|
||||
" GROUP BY t.recommendation_id"
|
||||
") WHERE max_pct > 0"
|
||||
" INNER JOIN ("
|
||||
" SELECT recommendation_id, MAX(id) as max_id "
|
||||
" FROM recommendation_tracking GROUP BY recommendation_id"
|
||||
" ) latest ON t.id = latest.max_id"
|
||||
") WHERE latest_pct > 0"
|
||||
)
|
||||
)
|
||||
winning = result.scalar() or 0
|
||||
|
||||
# 平均收益
|
||||
# 平均收益(基于最新跟踪日的 pct)
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT AVG(latest_pct) FROM ("
|
||||
" SELECT t.recommendation_id, t.pct_from_entry as latest_pct "
|
||||
" SELECT t.pct_from_entry as latest_pct "
|
||||
" FROM recommendation_tracking t "
|
||||
" INNER JOIN ("
|
||||
" SELECT recommendation_id, MAX(track_date) as max_date "
|
||||
" SELECT recommendation_id, MAX(id) as max_id "
|
||||
" FROM recommendation_tracking GROUP BY recommendation_id"
|
||||
" ) latest ON t.recommendation_id = latest.recommendation_id "
|
||||
" AND t.track_date = latest.max_date"
|
||||
" ) latest ON t.id = latest.max_id"
|
||||
")"
|
||||
)
|
||||
)
|
||||
@ -185,7 +188,7 @@ async def get_performance_stats() -> dict:
|
||||
)
|
||||
hit_stop_count = result.scalar() or 0
|
||||
|
||||
# 最近5条有跟踪的推荐详情
|
||||
# 最近跟踪的推荐详情
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT r.ts_code, r.name, r.signal, r.entry_price, "
|
||||
@ -195,10 +198,9 @@ async def get_performance_stats() -> dict:
|
||||
"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 "
|
||||
" SELECT recommendation_id, MAX(id) as max_id "
|
||||
" FROM recommendation_tracking GROUP BY recommendation_id"
|
||||
") latest ON t.recommendation_id = latest.recommendation_id "
|
||||
" AND t.track_date = latest.max_date "
|
||||
") latest ON t.id = latest.max_id "
|
||||
"ORDER BY r.created_at DESC LIMIT 20"
|
||||
)
|
||||
)
|
||||
@ -306,6 +308,8 @@ async def get_recommendation_history(days: int = 7) -> list[dict]:
|
||||
"sector_score": r["sector_score"] or 0,
|
||||
"capital_score": r["capital_score"] or 0,
|
||||
"technical_score": r["technical_score"] or 0,
|
||||
"supply_demand_score": r.get("supply_demand_score") or 0,
|
||||
"price_action_score": r.get("price_action_score") or 0,
|
||||
"position_score": r.get("position_score") or 50,
|
||||
"valuation_score": r.get("valuation_score") or 50,
|
||||
"entry_price": r["entry_price"],
|
||||
@ -395,18 +399,24 @@ async def _save_to_db(result: dict):
|
||||
capital_inflow=sector.capital_inflow,
|
||||
limit_up_count=sector.limit_up_count,
|
||||
heat_score=sector.heat_score,
|
||||
stage=sector.stage,
|
||||
days_continuous=sector.days_continuous,
|
||||
member_count=sector.member_count,
|
||||
leading_stocks=json.dumps(sector.leading_stocks, ensure_ascii=False),
|
||||
pct_trend=json.dumps(sector.pct_trend, ensure_ascii=False),
|
||||
turnover_avg=sector.turnover_avg,
|
||||
main_force_ratio=sector.main_force_ratio,
|
||||
trade_date=trade_date_val,
|
||||
)
|
||||
await db.execute(stmt)
|
||||
|
||||
# 保存推荐(先清除今日旧推荐,避免重复)
|
||||
# 保存推荐(按 ts_code 清除当日旧记录,避免同一天多次扫描产生重复)
|
||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||
await db.execute(
|
||||
text("DELETE FROM recommendations WHERE date(created_at) = :today"),
|
||||
{"today": today_str},
|
||||
)
|
||||
import json
|
||||
for rec in result.get("recommendations", []):
|
||||
await db.execute(
|
||||
text("DELETE FROM recommendations WHERE date(created_at) = :today AND ts_code = :code"),
|
||||
{"today": today_str, "code": rec.ts_code},
|
||||
)
|
||||
stmt = tables.recommendations_table.insert().values(
|
||||
ts_code=rec.ts_code,
|
||||
name=rec.name,
|
||||
@ -416,6 +426,8 @@ async def _save_to_db(result: dict):
|
||||
sector_score=rec.sector_score,
|
||||
capital_score=rec.capital_score,
|
||||
technical_score=rec.technical_score,
|
||||
supply_demand_score=rec.supply_demand_score,
|
||||
price_action_score=rec.price_action_score,
|
||||
position_score=rec.position_score,
|
||||
valuation_score=rec.valuation_score,
|
||||
signal=rec.signal,
|
||||
@ -464,10 +476,11 @@ async def _load_today_from_db() -> dict:
|
||||
temperature=m["temperature"],
|
||||
)
|
||||
|
||||
# 加载推荐(取最近一个有数据的日期,按 ts_code 去重)
|
||||
# 加载推荐(取最近一个有数据的日期,按 ts_code 去重,只取 >= 60 分)
|
||||
result = await db.execute(
|
||||
text("SELECT * FROM recommendations "
|
||||
"WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) "
|
||||
"AND score >= 60 "
|
||||
"AND id IN (SELECT MAX(id) FROM recommendations "
|
||||
" WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) "
|
||||
" GROUP BY ts_code) "
|
||||
@ -486,6 +499,8 @@ async def _load_today_from_db() -> dict:
|
||||
sector_score=r["sector_score"] or 0,
|
||||
capital_score=r["capital_score"] or 0,
|
||||
technical_score=r["technical_score"] or 0,
|
||||
supply_demand_score=r.get("supply_demand_score") or 0,
|
||||
price_action_score=r.get("price_action_score") or 0,
|
||||
position_score=r.get("position_score") or 50,
|
||||
valuation_score=r.get("valuation_score") or 50,
|
||||
signal=r["signal"] or "HOLD",
|
||||
@ -532,14 +547,23 @@ async def _load_sectors_from_db() -> list[SectorInfo]:
|
||||
sectors = []
|
||||
for row in rows:
|
||||
r = row._mapping
|
||||
# Parse JSON fields with fallback
|
||||
leading_stocks = json.loads(r.get("leading_stocks") or "[]")
|
||||
pct_trend = json.loads(r.get("pct_trend") or "[]")
|
||||
sectors.append(SectorInfo(
|
||||
sector_code=r["sector_code"],
|
||||
sector_name=r["sector_name"],
|
||||
pct_change=r["pct_change"] or 0,
|
||||
capital_inflow=r["capital_inflow"] or 0,
|
||||
limit_up_count=r["limit_up_count"] or 0,
|
||||
days_continuous=0,
|
||||
days_continuous=r.get("days_continuous") or 0,
|
||||
heat_score=r["heat_score"] or 0,
|
||||
stage=r.get("stage") or "mid",
|
||||
member_count=r.get("member_count") or 0,
|
||||
leading_stocks=leading_stocks,
|
||||
pct_trend=pct_trend,
|
||||
turnover_avg=r.get("turnover_avg") or 0,
|
||||
main_force_ratio=r.get("main_force_ratio") or 0,
|
||||
))
|
||||
return sectors
|
||||
except Exception as e:
|
||||
|
||||
@ -124,8 +124,8 @@ async def run_screening(trade_date: str = None) -> dict:
|
||||
candidates, market_temp, hot_sectors, market_temp_score, intraday,
|
||||
)
|
||||
|
||||
# 过滤低质量推荐
|
||||
recommendations = [r for r in recommendations if r.score >= 40]
|
||||
# 过滤低质量推荐(低于60分不推荐)
|
||||
recommendations = [r for r in recommendations if r.score >= 60]
|
||||
|
||||
logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===")
|
||||
for r in recommendations[:5]:
|
||||
@ -562,6 +562,8 @@ async def _build_recommendations(
|
||||
sector_score=round(_get_sector_heat(sector, hot_sectors), 1),
|
||||
capital_score=round(_score_capital_simple(stock), 1),
|
||||
technical_score=round(trend_score, 1),
|
||||
supply_demand_score=round(supply_demand_score, 1),
|
||||
price_action_score=round(price_action_score, 1),
|
||||
position_score=round(position_score, 1),
|
||||
valuation_score=round(valuation_score, 1),
|
||||
signal=signal,
|
||||
|
||||
@ -1,135 +1,15 @@
|
||||
"""推荐结果 LLM 增强
|
||||
|
||||
扫描完成后异步调用 LLM,为每只推荐股票生成深度分析。
|
||||
现在统一使用 analysis_agent 模块进行深度分析。
|
||||
此文件保留为兼容入口,内部直接调用 analysis_agent。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from app.llm.client import chat_completion
|
||||
from app.llm.prompts import ENHANCE_SYSTEM_PROMPT, ENHANCE_USER_TEMPLATE
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def enhance_recommendations(result: dict) -> None:
|
||||
"""对推荐结果进行 LLM 增强分析(fire-and-forget)"""
|
||||
if not settings.deepseek_api_key:
|
||||
return
|
||||
|
||||
recommendations = result.get("recommendations", [])
|
||||
if not recommendations:
|
||||
return
|
||||
|
||||
market_temp = result.get("market_temp")
|
||||
hot_sectors = result.get("hot_sectors", [])
|
||||
|
||||
# 构建板块文本
|
||||
sectors_text = "\n".join(
|
||||
f"- {s.sector_name}: 涨幅{s.pct_change}%, 资金流入{s.capital_inflow}万, "
|
||||
f"涨停{s.limit_up_count}家, 热度{s.heat_score}分, 阶段={s.stage}"
|
||||
for s in hot_sectors[:5]
|
||||
) if hot_sectors else "暂无板块数据"
|
||||
|
||||
# 温度等级
|
||||
temp_val = market_temp.temperature if market_temp else 0
|
||||
if temp_val >= 60:
|
||||
temp_level = "积极"
|
||||
elif temp_val >= 30:
|
||||
temp_level = "谨慎"
|
||||
else:
|
||||
temp_level = "低迷"
|
||||
|
||||
enhanced_count = 0
|
||||
for rec in recommendations:
|
||||
try:
|
||||
user_msg = ENHANCE_USER_TEMPLATE.format(
|
||||
temperature=market_temp.temperature if market_temp else "N/A",
|
||||
temp_level=temp_level,
|
||||
up_count=market_temp.up_count if market_temp else 0,
|
||||
down_count=market_temp.down_count if market_temp else 0,
|
||||
limit_up_count=market_temp.limit_up_count if market_temp else 0,
|
||||
max_streak=market_temp.max_streak if market_temp else 0,
|
||||
broken_rate=market_temp.broken_rate if market_temp else 0,
|
||||
sectors_text=sectors_text,
|
||||
name=rec.name,
|
||||
ts_code=rec.ts_code,
|
||||
sector=rec.sector,
|
||||
score=rec.score,
|
||||
level=rec.level,
|
||||
market_temp_score=rec.market_temp_score,
|
||||
sector_score=rec.sector_score,
|
||||
capital_score=rec.capital_score,
|
||||
technical_score=rec.technical_score,
|
||||
position_score=rec.position_score,
|
||||
valuation_score=rec.valuation_score,
|
||||
signal=rec.signal,
|
||||
entry_price=rec.entry_price or "N/A",
|
||||
target_price=rec.target_price or "N/A",
|
||||
stop_loss=rec.stop_loss or "N/A",
|
||||
reasons=";".join(rec.reasons) if rec.reasons else "无",
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": ENHANCE_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_msg},
|
||||
]
|
||||
|
||||
resp = await chat_completion(messages)
|
||||
if resp and resp.content:
|
||||
rec.llm_analysis = resp.content.strip()
|
||||
enhanced_count += 1
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.warning(f"LLM 增强 {rec.ts_code} 被取消")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"LLM 增强 {rec.ts_code} 失败: {e}")
|
||||
|
||||
if enhanced_count > 0:
|
||||
# 更新数据库
|
||||
await _save_llm_analysis_to_db(recommendations)
|
||||
# 通过 WebSocket 通知前端
|
||||
await _broadcast_llm_ready(recommendations)
|
||||
|
||||
logger.info(f"LLM 增强完成: {enhanced_count}/{len(recommendations)} 条")
|
||||
|
||||
|
||||
async def _save_llm_analysis_to_db(recommendations: list) -> None:
|
||||
"""将 LLM 分析结果更新到数据库"""
|
||||
try:
|
||||
from app.db.database import get_db
|
||||
from sqlalchemy import text
|
||||
|
||||
async with get_db() as db:
|
||||
for rec in recommendations:
|
||||
if not rec.llm_analysis:
|
||||
continue
|
||||
await db.execute(
|
||||
text(
|
||||
"UPDATE recommendations SET llm_analysis = :analysis "
|
||||
"WHERE ts_code = :code AND date(created_at) = date('now', 'localtime') "
|
||||
"AND scan_session = :session"
|
||||
),
|
||||
{
|
||||
"analysis": rec.llm_analysis,
|
||||
"code": rec.ts_code,
|
||||
"session": rec.scan_session,
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"保存 LLM 分析到数据库失败: {e}")
|
||||
|
||||
|
||||
async def _broadcast_llm_ready(recommendations: list) -> None:
|
||||
"""通过 WebSocket 广播 LLM 分析完成事件"""
|
||||
try:
|
||||
from app.api.websocket import broadcast_update
|
||||
await broadcast_update({
|
||||
"type": "llm_analysis_ready",
|
||||
"count": len([r for r in recommendations if r.llm_analysis]),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"广播 LLM 分析完成失败: {e}")
|
||||
"""对推荐结果进行 LLM 增强分析(兼容入口,委托给 analysis_agent)"""
|
||||
from app.llm.analysis_agent import analyze_recommendations
|
||||
await analyze_recommendations(result)
|
||||
Binary file not shown.
@ -10,31 +10,6 @@
|
||||
"static/chunks/main-app.js",
|
||||
"static/css/app/layout.css",
|
||||
"static/chunks/app/layout.js"
|
||||
],
|
||||
"/stock/[code]/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/stock/[code]/page.js"
|
||||
],
|
||||
"/monitor/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/monitor/page.js"
|
||||
],
|
||||
"/recommendations/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/recommendations/page.js"
|
||||
],
|
||||
"/sectors/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/sectors/page.js"
|
||||
],
|
||||
"/diagnose/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/diagnose/page.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,7 @@
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [
|
||||
"static/chunks/react-refresh.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_buildManifest.js",
|
||||
@ -15,16 +13,7 @@
|
||||
"static/chunks/main-app.js"
|
||||
],
|
||||
"pages": {
|
||||
"/_app": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main.js",
|
||||
"static/chunks/pages/_app.js"
|
||||
],
|
||||
"/_error": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main.js",
|
||||
"static/chunks/pages/_error.js"
|
||||
]
|
||||
"/_app": []
|
||||
},
|
||||
"ampFirstPages": []
|
||||
}
|
||||
2
frontend/.next/cache/.tsbuildinfo
vendored
2
frontend/.next/cache/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
@ -1,20 +1 @@
|
||||
{
|
||||
"app/sectors/page.tsx -> echarts": {
|
||||
"id": "app/sectors/page.tsx -> echarts",
|
||||
"files": [
|
||||
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
|
||||
]
|
||||
},
|
||||
"components/capital-flow.tsx -> echarts": {
|
||||
"id": "components/capital-flow.tsx -> echarts",
|
||||
"files": [
|
||||
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
|
||||
]
|
||||
},
|
||||
"components/kline-chart.tsx -> echarts": {
|
||||
"id": "components/kline-chart.tsx -> echarts",
|
||||
"files": [
|
||||
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
{}
|
||||
@ -1,8 +1,3 @@
|
||||
{
|
||||
"/page": "app/page.js",
|
||||
"/stock/[code]/page": "app/stock/[code]/page.js",
|
||||
"/monitor/page": "app/monitor/page.js",
|
||||
"/recommendations/page": "app/recommendations/page.js",
|
||||
"/sectors/page": "app/sectors/page.js",
|
||||
"/diagnose/page": "app/diagnose/page.js"
|
||||
"/page": "app/page.js"
|
||||
}
|
||||
@ -2,9 +2,7 @@ self.__BUILD_MANIFEST = {
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [
|
||||
"static/chunks/react-refresh.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [],
|
||||
"rootMainFiles": [
|
||||
@ -12,16 +10,7 @@ self.__BUILD_MANIFEST = {
|
||||
"static/chunks/main-app.js"
|
||||
],
|
||||
"pages": {
|
||||
"/_app": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main.js",
|
||||
"static/chunks/pages/_app.js"
|
||||
],
|
||||
"/_error": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main.js",
|
||||
"static/chunks/pages/_error.js"
|
||||
]
|
||||
"/_app": []
|
||||
},
|
||||
"ampFirstPages": []
|
||||
};
|
||||
|
||||
@ -1 +1 @@
|
||||
self.__REACT_LOADABLE_MANIFEST="{\"app/sectors/page.tsx -> echarts\":{\"id\":\"app/sectors/page.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/capital-flow.tsx -> echarts\":{\"id\":\"components/capital-flow.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/kline-chart.tsx -> echarts\":{\"id\":\"components/kline-chart.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}"
|
||||
self.__REACT_LOADABLE_MANIFEST="{}"
|
||||
@ -1,5 +1 @@
|
||||
{
|
||||
"/_app": "pages/_app.js",
|
||||
"/_error": "pages/_error.js",
|
||||
"/_document": "pages/_document.js"
|
||||
}
|
||||
{}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "ivfiGgdCRmC7fJWUxiqW3o7IY5TIF27ivPa+HF5AgdE="
|
||||
"encryptionKey": "s33pKc3VjlvggFQFOCpPXrHp6MilpQP7rFdwHOWbtO8="
|
||||
}
|
||||
@ -125,7 +125,7 @@
|
||||
/******/
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.h = () => ("53866692304f1888")
|
||||
/******/ __webpack_require__.h = () => ("668607dc222a6966")
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -3,6 +3,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { streamChat, type ChatMessage } from "@/lib/api";
|
||||
import { formatMarkdown } from "@/lib/markdown";
|
||||
|
||||
interface DisplayMessage {
|
||||
role: "user" | "assistant";
|
||||
@ -216,18 +217,3 @@ export default function ChatPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Simple markdown to HTML (bold, lists, newlines) */
|
||||
function formatMarkdown(text: string): string {
|
||||
let html = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/^\s*[-*]\s+(.+)/gm, "<li>$1</li>")
|
||||
.replace(/\n/g, "<br>");
|
||||
// Wrap consecutive <li> items in <ul>
|
||||
html = html.replace(/(<li>.*?<\/li>(<br>)?)+/g, (match) => {
|
||||
return "<ul>" + match.replace(/<br>/g, "") + "</ul>";
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { fetchAPI, postAPI, type DiagnosisResult } from "@/lib/api";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { fetchAPI, type DiagnosisResult } from "@/lib/api";
|
||||
import { markdownToHtml } from "@/lib/markdown";
|
||||
import { ErrorBoundary } from "@/components/error-boundary";
|
||||
|
||||
interface SearchResult {
|
||||
ts_code: string;
|
||||
@ -12,17 +15,20 @@ interface SearchResult {
|
||||
|
||||
export default function DiagnosePage() {
|
||||
const { theme } = useTheme();
|
||||
const searchParams = useSearchParams();
|
||||
const codeParam = searchParams.get("code");
|
||||
const [input, setInput] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState("");
|
||||
const [result, setResult] = useState<DiagnosisResult | null>(null);
|
||||
const [cachedResult, setCachedResult] = useState<string | 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)) {
|
||||
@ -33,6 +39,12 @@ export default function DiagnosePage() {
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!codeParam) return;
|
||||
setInput(codeParam);
|
||||
runDiagnosis(codeParam);
|
||||
}, [codeParam]);
|
||||
|
||||
const searchStock = useCallback(async (keyword: string) => {
|
||||
if (!keyword.trim() || keyword.length < 1) {
|
||||
setSearchResults([]);
|
||||
@ -63,12 +75,10 @@ export default function DiagnosePage() {
|
||||
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();
|
||||
@ -78,16 +88,65 @@ export default function DiagnosePage() {
|
||||
if (!code) return;
|
||||
|
||||
setLoading(true);
|
||||
setStreamingContent("");
|
||||
setResult(null);
|
||||
setCachedResult(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);
|
||||
});
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`/api/stocks/${code}/diagnose`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
if (!res.body) throw new Error("No response body");
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let fullContent = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6).trim();
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
if (parsed.cached && parsed.diagnosis) {
|
||||
setCachedResult(parsed.diagnosis);
|
||||
fullContent = parsed.diagnosis;
|
||||
} else if (parsed.token) {
|
||||
fullContent += parsed.token;
|
||||
setStreamingContent(fullContent);
|
||||
} else if (parsed.error) {
|
||||
setResult({ status: "error", message: parsed.error });
|
||||
} else if (parsed.done) {
|
||||
if (fullContent) {
|
||||
setResult({ status: "ok", ts_code: parsed.ts_code || code, diagnosis: fullContent });
|
||||
const name = input.split(" (")[0] || parsed.ts_code || code;
|
||||
setHistory((prev) => {
|
||||
const filtered = prev.filter((h) => h.ts_code !== (parsed.ts_code || code));
|
||||
return [{ ts_code: parsed.ts_code || code, name }, ...filtered].slice(0, 10);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setResult({ status: "error", message: "诊断失败,请检查股票代码后重试" });
|
||||
@ -103,7 +162,10 @@ export default function DiagnosePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const displayContent = cachedResult || result?.diagnosis || streamingContent;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<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">
|
||||
@ -178,7 +240,7 @@ export default function DiagnosePage() {
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
{history.length > 0 && !result && (
|
||||
{history.length > 0 && !displayContent && (
|
||||
<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">
|
||||
@ -199,8 +261,8 @@ export default function DiagnosePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
{/* Streaming / Loading State */}
|
||||
{loading && !displayContent && (
|
||||
<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>
|
||||
@ -208,45 +270,59 @@ export default function DiagnosePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result && !loading && (
|
||||
{/* Streaming content */}
|
||||
{loading && displayContent && (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
||||
<span className="text-xs text-text-muted">AI 正在分析...</span>
|
||||
</div>
|
||||
<div className="text-sm text-text-secondary leading-relaxed whitespace-pre-line">
|
||||
{displayContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final Result */}
|
||||
{!loading && displayContent && (
|
||||
<div className="animate-fade-in-up">
|
||||
{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 className="glass-card-static p-5">
|
||||
<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 || codeParam}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-emerald-500/10 text-emerald-400 border border-emerald-500/15">
|
||||
{cachedResult ? "缓存" : "分析完成"}
|
||||
</span>
|
||||
</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) }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => runDiagnosis(result?.ts_code || codeParam || undefined)}
|
||||
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="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
|
||||
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(displayContent) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{result?.status === "error" && !loading && !displayContent && (
|
||||
<div className="glass-card-static p-8 text-center animate-fade-in-up">
|
||||
<div className="text-sm text-red-400 mb-2">诊断失败</div>
|
||||
<div className="text-xs text-text-muted">{result.message || "未知错误"}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!result && !loading && history.length === 0 && (
|
||||
{!result && !loading && !displayContent && history.length === 0 && (
|
||||
<div className="glass-card-static p-10 text-center animate-fade-in-up">
|
||||
<div className="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">
|
||||
@ -275,25 +351,6 @@ export default function DiagnosePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { fetchAPI } from "@/lib/api";
|
||||
import type { LimitsData, UnusualStock } from "@/lib/api";
|
||||
import { ErrorBoundary } from "@/components/error-boundary";
|
||||
|
||||
export default function MonitorPage() {
|
||||
const [tab, setTab] = useState<"limits" | "unusual">("limits");
|
||||
@ -33,7 +34,25 @@ export default function MonitorPage() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const isInTradingHours = useCallback(() => {
|
||||
const now = new Date();
|
||||
const hour = now.getHours();
|
||||
const minute = now.getMinutes();
|
||||
const day = now.getDay(); // 0=Sunday
|
||||
if (day === 0 || day === 6) return false;
|
||||
if ((hour === 9 && minute >= 30) || hour === 10 || (hour === 11 && minute <= 30)) return true;
|
||||
if (hour === 13 || hour === 14 || (hour === 15 && minute === 0)) return true;
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInTradingHours()) return;
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadData, isInTradingHours]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<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">
|
||||
@ -82,6 +101,7 @@ export default function MonitorPage() {
|
||||
<UnusualView stocks={unusualStocks} />
|
||||
)}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import SectorHeatmap from "@/components/sector-heatmap";
|
||||
import { useWebSocket } from "@/hooks/use-websocket";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { markdownToHtml } from "@/lib/markdown";
|
||||
|
||||
interface ScanStatus {
|
||||
is_trading: boolean;
|
||||
@ -61,7 +62,8 @@ export default function DashboardPage() {
|
||||
useWebSocket(
|
||||
useCallback(() => {
|
||||
loadData();
|
||||
}, [loadData])
|
||||
}, [loadData]),
|
||||
["llm_analysis_ready", "sector_scan_ready", "scan_complete"]
|
||||
);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
@ -253,22 +255,3 @@ export default function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@ -103,7 +103,9 @@ export default function RecommendationsPage() {
|
||||
return recs.filter((r) => r.level === filter);
|
||||
};
|
||||
|
||||
// 总数统计
|
||||
// 今日推荐数
|
||||
const todayCount = dayGroups.length > 0 ? applyFilter(dayGroups[0].recommendations).length : 0;
|
||||
// 累计总数
|
||||
const totalCount = dayGroups.reduce((sum, g) => sum + applyFilter(g.recommendations).length, 0);
|
||||
|
||||
return (
|
||||
@ -113,7 +115,8 @@ export default function RecommendationsPage() {
|
||||
<div>
|
||||
<h1 className="text-base sm:text-lg font-bold tracking-tight">推荐列表</h1>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
共 <span className="font-mono tabular-nums">{totalCount}</span> 只 ·
|
||||
今日 <span className="font-mono tabular-nums text-amber-400">{todayCount}</span> 只 ·
|
||||
<span className="font-mono tabular-nums ml-1">{totalCount}</span> 只累计 ·
|
||||
<span className="font-mono tabular-nums ml-1">{dayGroups.length}</span> 天记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,7 @@ import { fetchAPI } from "@/lib/api";
|
||||
import type { SectorData, LeadingStock, SectorRotationData } from "@/lib/api";
|
||||
import { formatNumber } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/hooks/use-websocket";
|
||||
import { ErrorBoundary } from "@/components/error-boundary";
|
||||
|
||||
function getStageInfo(stage: string) {
|
||||
switch (stage) {
|
||||
@ -224,6 +225,7 @@ export default function SectorsPage() {
|
||||
}, [showRotation, rotationData, loadRotation]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
||||
<div className="flex items-center justify-between mb-5 animate-fade-in-up">
|
||||
<div>
|
||||
@ -272,6 +274,7 @@ export default function SectorsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -3,14 +3,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { fetchAPI } from "@/lib/api";
|
||||
import type { RecommendationData, DayGroup } from "@/lib/api";
|
||||
import { getScoreColor } from "@/lib/utils";
|
||||
import KlineChart from "@/components/kline-chart";
|
||||
import CapitalFlowChart from "@/components/capital-flow";
|
||||
import { ErrorBoundary } from "@/components/error-boundary";
|
||||
|
||||
interface StockSignals {
|
||||
ts_code: string;
|
||||
name: string;
|
||||
score: number;
|
||||
trend_score: number;
|
||||
signal_count: number;
|
||||
ma_bullish: boolean;
|
||||
volume_breakout: boolean;
|
||||
@ -28,6 +31,14 @@ interface StockSignals {
|
||||
position_score: number;
|
||||
}
|
||||
|
||||
interface RecScore {
|
||||
supply_demand_score: number;
|
||||
price_action_score: number;
|
||||
technical_score: number;
|
||||
position_score: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface QuoteData {
|
||||
ts_code: string;
|
||||
name: string;
|
||||
@ -66,6 +77,7 @@ export default function StockDetailPage() {
|
||||
|
||||
const [quote, setQuote] = useState<QuoteData | null>(null);
|
||||
const [signals, setSignals] = useState<StockSignals | null>(null);
|
||||
const [recScore, setRecScore] = useState<RecScore | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [kline, setKline] = useState<any[]>([]);
|
||||
const [capitalFlow, setCapitalFlow] = useState<FlowRecord[]>([]);
|
||||
@ -83,11 +95,28 @@ export default function StockDetailPage() {
|
||||
setKline(k);
|
||||
setCapitalFlow(c as FlowRecord[]);
|
||||
});
|
||||
// 尝试从推荐历史中获取该股票的评分
|
||||
fetchAPI<DayGroup[]>(`/api/recommendations/history?days=14`).then((history) => {
|
||||
for (const group of history) {
|
||||
const rec = group.recommendations?.find((r) => r.ts_code === code);
|
||||
if (rec && rec.supply_demand_score) {
|
||||
setRecScore({
|
||||
supply_demand_score: rec.supply_demand_score,
|
||||
price_action_score: rec.price_action_score ?? 0,
|
||||
technical_score: rec.technical_score,
|
||||
position_score: rec.position_score ?? 50,
|
||||
score: rec.score,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}).catch(() => null);
|
||||
}, [code]);
|
||||
|
||||
const latestFlow = capitalFlow.length > 0 ? capitalFlow[capitalFlow.length - 1] : null;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="max-w-6xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-4">
|
||||
{/* Back */}
|
||||
<a
|
||||
@ -116,8 +145,18 @@ export default function StockDetailPage() {
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-bold tracking-tight">{quote.name}</span>
|
||||
<span className="text-xs text-text-muted font-mono tabular-nums">{quote.ts_code}</span>
|
||||
<span className="text-sm text-text-muted font-mono tabular-nums">{quote.ts_code}</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/diagnose?code=${code}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-lg hover:from-amber-500/30 hover:to-amber-600/25 transition-all border border-amber-500/10"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
AI 诊断
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span
|
||||
@ -140,14 +179,14 @@ export default function StockDetailPage() {
|
||||
{quote.pct_chg.toFixed(2)}%
|
||||
</span>
|
||||
{quote.pre_close && (
|
||||
<span className="text-xs text-text-muted font-mono tabular-nums">
|
||||
<span className="text-sm text-text-muted font-mono tabular-nums">
|
||||
昨收 {quote.pre_close.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OHLC row */}
|
||||
<div className="grid grid-cols-4 gap-2 mt-4">
|
||||
<div className="grid grid-cols-4 gap-3 mt-4">
|
||||
<MiniStat
|
||||
label="开盘"
|
||||
value={quote.open && quote.open > 0 ? quote.open.toFixed(2) : "-"}
|
||||
@ -176,7 +215,7 @@ export default function StockDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Valuation row */}
|
||||
<div className="grid grid-cols-4 gap-2 mt-2">
|
||||
<div className="grid grid-cols-4 gap-3 mt-3">
|
||||
<MiniStat label="换手率" value={`${quote.turnover_rate?.toFixed(2)}%`} />
|
||||
<MiniStat label="市盈率" value={quote.pe?.toFixed(1) ?? "-"} />
|
||||
<MiniStat label="市净率" value={quote.pb?.toFixed(2) ?? "-"} />
|
||||
@ -188,7 +227,7 @@ export default function StockDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Market cap row */}
|
||||
<div className="grid grid-cols-4 gap-2 mt-2">
|
||||
<div className="grid grid-cols-4 gap-3 mt-3">
|
||||
<MiniStat
|
||||
label="总市值"
|
||||
value={quote.total_mv ? `${formatBigNum(quote.total_mv)}亿` : "-"}
|
||||
@ -217,40 +256,31 @@ export default function StockDetailPage() {
|
||||
{/* Position Safety Card */}
|
||||
{signals && (
|
||||
<div className="glass-card-static p-5">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4">
|
||||
仓位安全评估
|
||||
</h2>
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Score ring */}
|
||||
<div className="relative w-24 h-24 flex-shrink-0">
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full -rotate-90">
|
||||
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--surface-2)" strokeWidth="8" />
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="42"
|
||||
fill="none"
|
||||
stroke={getPositionColor(signals.position_score)}
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${(signals.position_score / 100) * 264} 264`}
|
||||
className="transition-all duration-700"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={`text-xl font-bold font-mono tabular-nums ${getPositionColor(signals.position_score)}`}>
|
||||
{Math.round(signals.position_score)}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted">安全分</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Metrics */}
|
||||
<div className="flex-1 space-y-3 min-w-0">
|
||||
<PositionBar label="5日涨幅" value={signals.rally_pct_5d} />
|
||||
<PositionBar label="10日涨幅" value={signals.rally_pct_10d} />
|
||||
<PositionBar label="距高点" value={signals.distance_from_high} invert />
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="w-1 h-4 rounded-full" style={{ backgroundColor: getPositionColor(signals.position_score) }} />
|
||||
<h2 className="text-sm font-bold tracking-tight">
|
||||
仓位安全评估
|
||||
</h2>
|
||||
<span className={`text-lg font-bold font-mono tabular-nums ml-auto`} style={{ color: getPositionColor(signals.position_score) }}>
|
||||
{Math.round(signals.position_score)}
|
||||
<span className="text-[10px] text-text-muted ml-0.5">安全分</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* 位置安全评分条 */}
|
||||
<div className="mb-4">
|
||||
<div className="h-2.5 bg-surface-3 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-700 ease-out"
|
||||
style={{ width: `${Math.min(signals.position_score, 100)}%`, backgroundColor: getPositionColor(signals.position_score) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<PositionBar label="5日涨幅" value={signals.rally_pct_5d} />
|
||||
<PositionBar label="10日涨幅" value={signals.rally_pct_10d} />
|
||||
<PositionBar label="距高点" value={signals.distance_from_high} invert />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -263,45 +293,73 @@ export default function StockDetailPage() {
|
||||
{/* Technical signals */}
|
||||
{signals && (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up delay-75">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
技术面信号
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-sm font-bold tracking-tight">
|
||||
技术面分析
|
||||
</h2>
|
||||
<div className={`text-lg font-bold font-mono tabular-nums ${getScoreColor(signals.score)}`}>
|
||||
{signals.score}
|
||||
<span className="text-xs text-text-muted ml-0.5">分</span>
|
||||
{recScore && (
|
||||
<div className={`text-2xl font-bold font-mono tabular-nums ${getScoreColor(recScore.score)}`}>
|
||||
{recScore.score}
|
||||
<span className="text-xs text-text-muted ml-1">综合分</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Module 1: 核心评分维度 ── */}
|
||||
<div className="mb-5 pb-5 border-b border-border-subtle">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-1 h-4 rounded-full bg-amber-500/70" />
|
||||
<span className="text-xs font-semibold text-text-secondary">核心评分维度</span>
|
||||
{recScore && (
|
||||
<span className="text-[10px] text-text-muted/40 ml-1">综合 = 供需×50% + 形态×40% + 趋势×10%</span>
|
||||
)}
|
||||
</div>
|
||||
{recScore ? (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<DimensionScore label="供需关系" sublabel="50%权重" value={recScore.supply_demand_score} />
|
||||
<DimensionScore label="价格形态" sublabel="40%权重" value={recScore.price_action_score} />
|
||||
<DimensionScore label="趋势方向" sublabel="10%权重" value={recScore.technical_score} />
|
||||
<DimensionScore label="位置安全" sublabel="防追高" value={recScore.position_score} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DimensionScore label="趋势评分" sublabel="均线排列+结构+MA20方向" value={signals.trend_score} />
|
||||
<DimensionScore label="信号计数" sublabel="触发即加分,仅供参考" value={(signals.signal_count / 7) * 100} displayValue={`${signals.signal_count}/7`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Module 2: 辅助信号 ── */}
|
||||
<div className="mb-5 pb-5 border-b border-border-subtle">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-1 h-4 rounded-full bg-cyan-500/70" />
|
||||
<span className="text-xs font-semibold text-text-secondary">辅助信号</span>
|
||||
<span className="text-[10px] text-text-muted/30">·触发即加分,不参与主评分</span>
|
||||
<span className={`text-xs font-mono tabular-nums ml-auto ${signals.signal_count >= 4 ? "text-amber-400" : signals.signal_count >= 2 ? "text-cyan-400" : "text-text-muted"}`}>
|
||||
{signals.signal_count}/7
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<SignalChip label="均线多头" active={signals.ma_bullish} points={15} />
|
||||
<SignalChip label="放量突破" active={signals.volume_breakout} points={20} />
|
||||
<SignalChip label="MACD金叉" active={signals.macd_golden} points={15} />
|
||||
<SignalChip label="RSI健康" active={signals.rsi_healthy} points={10} />
|
||||
<SignalChip label="缩量回踩" active={signals.pullback_support} points={15} />
|
||||
<SignalChip label="放量长阳" active={signals.big_yang} points={15} />
|
||||
<SignalChip label="布林支撑" active={signals.boll_support} points={10} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<SignalItem label="均线多头" active={signals.ma_bullish} points={15} />
|
||||
<SignalItem label="放量突破" active={signals.volume_breakout} points={20} />
|
||||
<SignalItem label="MACD金叉" active={signals.macd_golden} points={15} />
|
||||
<SignalItem label="RSI健康" active={signals.rsi_healthy} points={10} />
|
||||
<SignalItem label="缩量回踩" active={signals.pullback_support} points={15} />
|
||||
<SignalItem label="放量长阳" active={signals.big_yang} points={15} />
|
||||
<SignalItem label="布林支撑" active={signals.boll_support} points={10} />
|
||||
</div>
|
||||
|
||||
{/* Price levels */}
|
||||
<div className="flex justify-between mt-4 pt-4 border-t border-border-subtle text-xs">
|
||||
<div>
|
||||
<span className="text-text-muted">支撑位 </span>
|
||||
<span className="text-orange-400 font-mono tabular-nums">
|
||||
{signals.support_price?.toFixed(2) ?? "-"}
|
||||
</span>
|
||||
{/* ── Module 3: 关键价位 ── */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-1 h-4 rounded-full bg-emerald-500/70" />
|
||||
<span className="text-xs font-semibold text-text-secondary">关键价位</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">压力位 </span>
|
||||
<span className="text-red-400 font-mono tabular-nums">
|
||||
{signals.resist_price?.toFixed(2) ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">止损位 </span>
|
||||
<span className="text-emerald-400 font-mono tabular-nums">
|
||||
{signals.stop_loss_price?.toFixed(2) ?? "-"}
|
||||
</span>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<PriceLevel label="支撑位" value={signals.support_price} color="text-orange-400" />
|
||||
<PriceLevel label="压力位" value={signals.resist_price} color="text-red-400" />
|
||||
<PriceLevel label="止损位" value={signals.stop_loss_price} color="text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -313,6 +371,7 @@ export default function StockDetailPage() {
|
||||
{capitalFlow.length > 0 && <CapitalFlowChart data={capitalFlow} />}
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@ -330,9 +389,9 @@ function MiniStat({
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-lg px-2.5 py-1.5 border ${highlight ? "border-amber-500/20 bg-amber-500/[0.04]" : "border-border-subtle bg-surface-1"}`}>
|
||||
<div className="text-[10px] text-text-muted leading-tight">{label}</div>
|
||||
<div className={`text-xs font-mono tabular-nums ${color ?? ""}`}>
|
||||
<div className={`rounded-xl px-3 py-2.5 border ${highlight ? "border-amber-500/20 bg-amber-500/[0.04]" : "border-border-subtle bg-surface-1"}`}>
|
||||
<div className="text-[11px] text-text-muted leading-tight">{label}</div>
|
||||
<div className={`text-sm font-bold font-mono tabular-nums mt-0.5 ${color ?? ""}`}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
@ -345,33 +404,19 @@ function PositionBar({ label, value, invert = false }: { label: string; value: n
|
||||
const pct = Math.min(absVal / maxDisplay, 1) * 100;
|
||||
const isPositive = value > 0;
|
||||
const showWarning = invert ? value < -20 : value > 20;
|
||||
const barColor = showWarning ? "bg-amber-400" : isPositive ? "bg-red-400" : "bg-emerald-400";
|
||||
const textColor = showWarning ? "text-amber-400" : isPositive ? "text-red-400" : "text-emerald-400";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-text-muted">{label}</span>
|
||||
<span
|
||||
className={`font-mono tabular-nums ${
|
||||
showWarning
|
||||
? "text-amber-400"
|
||||
: isPositive
|
||||
? "text-red-400"
|
||||
: "text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{isPositive ? "+" : ""}
|
||||
{value.toFixed(1)}%
|
||||
</span>
|
||||
<div className="bg-surface-1 rounded-xl px-3 py-2.5 border border-border-subtle">
|
||||
<div className="text-[10px] text-text-muted leading-tight">{label}</div>
|
||||
<div className={`text-sm font-bold font-mono tabular-nums ${textColor}`}>
|
||||
{isPositive ? "+" : ""}
|
||||
{value.toFixed(1)}%
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-surface-2 overflow-hidden">
|
||||
<div className="h-1.5 rounded-full bg-surface-3 overflow-hidden mt-1.5">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
showWarning
|
||||
? "bg-amber-400"
|
||||
: isPositive
|
||||
? "bg-red-400"
|
||||
: "bg-emerald-400"
|
||||
}`}
|
||||
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
@ -384,28 +429,28 @@ function CapitalFlowBreakdown({ flow }: { flow: FlowRecord }) {
|
||||
...[flow.elg_net, flow.lg_net, flow.md_net, flow.sm_net].map(Math.abs),
|
||||
1
|
||||
);
|
||||
const isMainInflow = flow.main_net_inflow > 0;
|
||||
return (
|
||||
<div className="glass-card-static p-5">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4">
|
||||
今日资金流向
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className={`w-1 h-4 rounded-full ${isMainInflow ? "bg-red-500/70" : "bg-emerald-500/70"}`} />
|
||||
<h2 className="text-sm font-bold tracking-tight">
|
||||
今日资金流向
|
||||
</h2>
|
||||
<span
|
||||
className={`text-lg font-bold font-mono tabular-nums ml-auto ${isMainInflow ? "text-red-400" : "text-emerald-400"}`}
|
||||
>
|
||||
{isMainInflow ? "+" : ""}
|
||||
{formatFlowAmount(flow.main_net_inflow)}
|
||||
<span className="text-[10px] text-text-muted ml-0.5">主力净流入</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<FlowBar label="特大单" value={flow.elg_net} max={maxDisplay} />
|
||||
<FlowBar label="大单" value={flow.lg_net} max={maxDisplay} />
|
||||
<FlowBar label="中单" value={flow.md_net} max={maxDisplay} />
|
||||
<FlowBar label="小单" value={flow.sm_net} max={maxDisplay} />
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-border-subtle flex items-center justify-between text-xs">
|
||||
<span className="text-text-muted">主力净流入</span>
|
||||
<span
|
||||
className={`font-mono tabular-nums font-semibold ${
|
||||
flow.main_net_inflow > 0 ? "text-red-400" : "text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{flow.main_net_inflow > 0 ? "+" : ""}
|
||||
{formatFlowAmount(flow.main_net_inflow)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -438,19 +483,57 @@ function FlowBar({ label, value, max }: { label: string; value: number; max: num
|
||||
);
|
||||
}
|
||||
|
||||
function SignalItem({ label, active, points }: { label: string; active: boolean; points: number }) {
|
||||
function SignalChip({ label, active, points }: { label: string; active: boolean; points: number }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-xl text-xs transition-all duration-200 ${
|
||||
className={`flex flex-col items-center py-2.5 rounded-xl transition-all duration-200 ${
|
||||
active
|
||||
? "bg-red-500/[0.08] text-red-400 border border-red-500/10"
|
||||
: "bg-surface-1 text-text-muted border border-transparent"
|
||||
? "bg-red-500/[0.08] border border-red-500/15"
|
||||
: "bg-surface-1 border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className={`font-mono tabular-nums ${active ? "font-semibold" : ""}`}>
|
||||
{active ? `+${points}` : "0"}
|
||||
<span className={`text-[11px] font-medium ${active ? "text-red-400" : "text-text-muted/60"}`}>
|
||||
{label}
|
||||
</span>
|
||||
<span className={`text-[11px] font-mono tabular-nums mt-0.5 ${active ? "text-amber-400 font-semibold" : "text-text-muted/30"}`}>
|
||||
{active ? `+${points}` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DimensionScore({ label, sublabel, value, displayValue }: { label: string; sublabel: string; value: number; displayValue?: string }) {
|
||||
const width = Math.min(value, 100);
|
||||
const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low";
|
||||
const scoreColor = value >= 70 ? "text-amber-400" : value >= 50 ? "text-cyan-400" : "text-text-muted";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-1.5">
|
||||
<div>
|
||||
<span className={`text-xs font-semibold ${scoreColor}`}>{label}</span>
|
||||
<span className="text-[10px] text-text-muted/40 ml-1">{sublabel}</span>
|
||||
</div>
|
||||
<span className={`text-sm font-bold font-mono tabular-nums ${scoreColor}`}>
|
||||
{displayValue ?? value.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-surface-3 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-700 ease-out ${gradientClass}`}
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceLevel({ label, value, color }: { label: string; value: number | null; color: string }) {
|
||||
return (
|
||||
<div className="bg-surface-1 rounded-xl px-3 py-2.5 border border-border-subtle">
|
||||
<div className="text-[10px] text-text-muted leading-tight">{label}</div>
|
||||
<div className={`text-sm font-bold font-mono tabular-nums ${color}`}>
|
||||
{value?.toFixed(2) ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
37
frontend/src/components/error-boundary.tsx
Normal file
37
frontend/src/components/error-boundary.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { Component, ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback || (
|
||||
<div className="glass-card-static p-8 text-center">
|
||||
<div className="text-sm text-red-400 mb-2">页面加载出错</div>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false })}
|
||||
className="text-xs px-3 py-1.5 bg-surface-2 rounded-lg text-text-secondary hover:text-text-primary transition-all"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,7 @@ export default function KlineChart({ data }: { data: KlineData[] }) {
|
||||
if (!chartRef.current || !data.length) return;
|
||||
|
||||
let chart: ReturnType<typeof import("echarts")["init"]> | null = null;
|
||||
let resizeHandler: (() => void) | null = null;
|
||||
|
||||
import("echarts").then((ec) => {
|
||||
if (!chartRef.current) return;
|
||||
@ -141,12 +142,14 @@ export default function KlineChart({ data }: { data: KlineData[] }) {
|
||||
],
|
||||
});
|
||||
|
||||
const handleResize = () => chart?.resize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
resizeHandler = () => chart?.resize();
|
||||
window.addEventListener("resize", resizeHandler);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (resizeHandler) {
|
||||
window.removeEventListener("resize", resizeHandler);
|
||||
}
|
||||
chart?.dispose();
|
||||
};
|
||||
}, [data, theme]);
|
||||
|
||||
@ -53,6 +53,14 @@ function DiagnoseIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function ChatIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
@ -93,6 +101,7 @@ export function SidebarNav() {
|
||||
<SideNavItem href="/monitor" icon={<MonitorIcon />} label="监控" />
|
||||
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块分析" />
|
||||
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="AI 诊断" />
|
||||
<SideNavItem href="/chat" icon={<ChatIcon />} label="AI 对话" />
|
||||
{user?.role === "admin" && (
|
||||
<SideNavItem href="/users" icon={<UsersIcon />} label="用户管理" />
|
||||
)}
|
||||
@ -136,6 +145,9 @@ export function MobileBottomNav() {
|
||||
<MobileNavItem href="/diagnose" label="诊断">
|
||||
<DiagnoseIcon />
|
||||
</MobileNavItem>
|
||||
<MobileNavItem href="/chat" label="对话">
|
||||
<ChatIcon />
|
||||
</MobileNavItem>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@ -1,12 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { getLevelBadge, getSignalColor, getScoreColor } from "@/lib/utils";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { getLevelBadge, getScoreColor } from "@/lib/utils";
|
||||
import type { RecommendationData } from "@/lib/api";
|
||||
|
||||
export default function StockCard({ rec, showLLMLoading = false }: { rec: RecommendationData; showLLMLoading?: boolean }) {
|
||||
const badge = getLevelBadge(rec.level);
|
||||
const [aiExpanded, setAiExpanded] = useState(false);
|
||||
const aiContentRef = useRef<HTMLDivElement>(null);
|
||||
const [aiContentHeight, setAiContentHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (aiContentRef.current) {
|
||||
setAiContentHeight(aiContentRef.current.scrollHeight);
|
||||
}
|
||||
}, [aiExpanded, rec.llm_analysis]);
|
||||
|
||||
// 入场信号标签
|
||||
const signalTypeMap: Record<string, { label: string; style: string }> = {
|
||||
@ -23,134 +31,127 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
|
||||
: null;
|
||||
const tag = signalInfo || legacyStrategy;
|
||||
|
||||
const hasLLM = rec.llm_analysis && rec.llm_analysis !== "AI 分析暂时不可用";
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/stock/${rec.ts_code}`}
|
||||
className="block glass-card p-3 sm:p-4 md:p-5 group"
|
||||
>
|
||||
{/* Header: Name + Strategy + Score */}
|
||||
<div className="flex items-start justify-between mb-2 sm:mb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span className="font-semibold text-sm tracking-tight">{rec.name}</span>
|
||||
<span className={`text-[10px] sm:text-xs px-1 py-0.5 rounded-full font-medium ${badge.bg} ${badge.text}`}>
|
||||
{rec.level}
|
||||
</span>
|
||||
{tag && (
|
||||
<span className={`text-[9px] sm:text-[10px] px-1 py-0.5 rounded-md font-medium border ${tag.style}`}>
|
||||
{tag.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] sm:text-[11px] text-text-muted mt-0.5 sm:mt-1 font-mono tabular-nums truncate">
|
||||
{rec.ts_code} <span className="text-text-muted/40 mx-0.5">·</span> {rec.sector}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-2 sm:ml-3">
|
||||
<div className={`text-lg sm:text-xl font-bold font-mono tabular-nums tracking-tight ${getScoreColor(rec.score)}`}>
|
||||
{rec.score}
|
||||
</div>
|
||||
<div className={`text-[10px] sm:text-xs font-semibold tracking-wider ${getSignalColor(rec.signal)}`}>
|
||||
{rec.signal === "BUY" ? "买入" : rec.signal === "SELL" ? "卖出" : "持有"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Four dimension score bars */}
|
||||
<div className="grid grid-cols-4 gap-1 sm:gap-2 mb-3 sm:mb-4">
|
||||
<ScoreBar label="市场" value={rec.market_temp_score} />
|
||||
<ScoreBar label="板块" value={rec.sector_score} />
|
||||
<ScoreBar label="资金" value={rec.capital_score} />
|
||||
<ScoreBar label="技术" value={rec.technical_score} />
|
||||
</div>
|
||||
|
||||
{/* Price reference */}
|
||||
{rec.entry_price && (
|
||||
<div className="grid grid-cols-3 gap-1.5 sm:gap-2 mb-2 sm:mb-3 bg-surface-2 rounded-lg sm:rounded-xl px-2.5 sm:px-3 md:px-4 py-2 border border-border-subtle text-[10px] sm:text-xs">
|
||||
<div>
|
||||
<span className="text-text-muted block text-[9px] sm:text-[10px]">买入</span>
|
||||
<span className="text-red-400 font-mono tabular-nums">{rec.entry_price}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted block text-[9px] sm:text-[10px]">目标</span>
|
||||
<span className="text-amber-400 font-mono tabular-nums">{rec.target_price}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted block text-[9px] sm:text-[10px]">止损</span>
|
||||
<span className="text-emerald-400 font-mono tabular-nums">{rec.stop_loss}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reasons - show max 2 on mobile, 3 on desktop */}
|
||||
<div className="space-y-1 sm:space-y-1.5">
|
||||
{rec.reasons.slice(0, 2).map((r, i) => (
|
||||
<div key={i} className="text-[10px] sm:text-xs text-text-secondary flex items-start gap-1.5 sm:gap-2">
|
||||
<span className="w-1 h-1 rounded-full bg-amber-500/60 mt-[5px] sm:mt-[7px] shrink-0" />
|
||||
<span className="leading-relaxed line-clamp-2">{r}</span>
|
||||
</div>
|
||||
))}
|
||||
{rec.reasons.length > 2 && (
|
||||
<div className="text-[9px] sm:text-[10px] text-text-muted/60">+{rec.reasons.length - 2}条原因</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Analysis — collapsible */}
|
||||
{rec.llm_analysis && rec.llm_analysis !== "AI 分析暂时不可用" ? (
|
||||
<div className="mt-2 sm:mt-3">
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); setAiExpanded(!aiExpanded); }}
|
||||
className="w-full flex items-center justify-between bg-accent-cyan/[0.06] border border-accent-cyan/[0.12] rounded-lg sm:rounded-xl px-3 sm:px-4 py-2 hover:bg-accent-cyan/[0.09] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs text-accent-cyan/80 font-semibold tracking-wider">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${aiExpanded ? "rotate-90" : ""}`}>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
AI 分析
|
||||
<div className="glass-card p-4 group">
|
||||
{/* Clickable top section — navigates to stock detail */}
|
||||
<a href={`/stock/${rec.ts_code}`} className="block">
|
||||
{/* Header: Name + Strategy + Score */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="font-semibold text-sm tracking-tight">{rec.name}</span>
|
||||
{rec.signal === "BUY" && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-md font-medium bg-red-500/15 text-red-400 border border-red-500/20">
|
||||
买入
|
||||
</span>
|
||||
)}
|
||||
{tag && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium border ${tag.style}`}>
|
||||
{tag.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted mt-1 font-mono tabular-nums">
|
||||
{rec.ts_code} · {rec.sector}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-3">
|
||||
<div className={`text-xl font-bold font-mono tabular-nums tracking-tight ${getScoreColor(rec.score)}`}>
|
||||
{rec.score}
|
||||
</div>
|
||||
<div className={`text-[10px] font-medium ${badge.text}`}>
|
||||
{rec.level}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score dimension bars */}
|
||||
<div className="grid grid-cols-4 gap-2 mb-3">
|
||||
<ScoreBar label="供需" value={rec.supply_demand_score ?? 0} weight="50%" />
|
||||
<ScoreBar label="形态" value={rec.price_action_score ?? 0} weight="40%" />
|
||||
<ScoreBar label="趋势" value={rec.technical_score} weight="10%" />
|
||||
<ScoreBar label="位置" value={rec.position_score ?? 50} weight="防追高" />
|
||||
</div>
|
||||
|
||||
{/* Price reference */}
|
||||
{rec.entry_price && (
|
||||
<div className="grid grid-cols-3 gap-2 mb-2 bg-surface-1/60 rounded-lg px-3 py-2 border border-border-subtle">
|
||||
<div>
|
||||
<span className="text-text-muted/60 block text-[10px]">买入</span>
|
||||
<span className="text-red-400 font-mono tabular-nums text-xs">{rec.entry_price}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted/60 block text-[10px]">目标</span>
|
||||
<span className="text-amber-400 font-mono tabular-nums text-xs">{rec.target_price}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted/60 block text-[10px]">止损</span>
|
||||
<span className="text-emerald-400 font-mono tabular-nums text-xs">{rec.stop_loss}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reasons */}
|
||||
<div className="space-y-1.5">
|
||||
{rec.reasons.slice(0, 3).map((r, i) => (
|
||||
<div key={i} className="text-xs text-text-secondary flex items-start gap-2">
|
||||
<span className="w-1 h-1 rounded-full bg-amber-500/60 mt-[6px] shrink-0" />
|
||||
<span className="leading-relaxed line-clamp-2">{r}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* ── AI Analysis — separate from the clickable link area ── */}
|
||||
{hasLLM ? (
|
||||
<div className="mt-3 border-t border-border-subtle pt-3">
|
||||
<button
|
||||
onClick={() => setAiExpanded(!aiExpanded)}
|
||||
className="w-full flex items-center gap-2 text-xs text-cyan-400/80 font-medium hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${aiExpanded ? "rotate-90" : ""}`}>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
AI 分析
|
||||
{rec.llm_score != null && (
|
||||
<div className="text-[10px] sm:text-xs font-mono tabular-nums">
|
||||
<span className="text-text-muted">评分 </span>
|
||||
<span className="ml-auto font-mono tabular-nums">
|
||||
<span className={`font-bold ${rec.llm_score >= 7 ? "text-amber-400" : rec.llm_score >= 5 ? "text-cyan-400" : "text-text-muted"}`}>
|
||||
{rec.llm_score}
|
||||
</span>
|
||||
<span className="text-text-muted/50">/10</span>
|
||||
</div>
|
||||
<span className="text-text-muted/40">/10</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{aiExpanded && (
|
||||
<div className="text-[10px] sm:text-xs text-text-secondary leading-relaxed whitespace-pre-line bg-accent-cyan/[0.03] border border-t-0 border-accent-cyan/[0.08] rounded-b-lg sm:rounded-b-xl px-3 sm:px-4 py-2 sm:py-3">
|
||||
<MarkdownText text={rec.llm_analysis} />
|
||||
<div
|
||||
className="overflow-hidden transition-[max-height] duration-300 ease-out"
|
||||
style={{ maxHeight: aiExpanded ? aiContentHeight + 20 : 0 }}
|
||||
>
|
||||
<div ref={aiContentRef} className="text-xs text-text-secondary leading-relaxed whitespace-pre-line mt-2 pl-1">
|
||||
<MarkdownText text={rec.llm_analysis ?? ""} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : rec.llm_analysis === "AI 分析暂时不可用" ? (
|
||||
<div className="mt-2 sm:mt-3 text-[10px] sm:text-xs text-text-muted/50 flex items-center gap-1.5">
|
||||
<span className="w-1 h-1 rounded-full bg-text-muted/30" />
|
||||
AI 分析暂时不可用
|
||||
<div className="mt-3 border-t border-border-subtle pt-2 text-xs text-text-muted/40 flex items-center gap-1.5">
|
||||
<span className="w-1 h-1 rounded-full bg-text-muted/20" />
|
||||
AI 分析暂不可用
|
||||
</div>
|
||||
) : showLLMLoading ? (
|
||||
<div className="mt-2 sm:mt-3 text-[10px] sm:text-xs text-text-muted flex items-center gap-2">
|
||||
<span className="inline-block w-3 h-3 border border-accent-cyan/30 border-t-accent-cyan/80 rounded-full animate-spin" />
|
||||
<div className="mt-3 border-t border-border-subtle pt-2 text-xs text-text-muted flex items-center gap-2">
|
||||
<span className="inline-block w-3 h-3 border border-cyan-400/30 border-t-cyan-400/80 rounded-full animate-spin" />
|
||||
AI 分析中...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Risk note */}
|
||||
{rec.risk_note && (
|
||||
<div className="mt-2 sm:mt-3 text-[10px] sm:text-xs text-amber-500/60 bg-amber-500/[0.04] border border-amber-500/[0.08] rounded-lg px-2.5 sm:px-3 py-1.5">
|
||||
{rec.risk_note}
|
||||
<div className="mt-2 text-[11px] text-amber-500/50 bg-amber-500/[0.04] rounded-lg px-3 py-1.5">
|
||||
⚠ {rec.risk_note}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover indicator - hidden on mobile */}
|
||||
<div className="mt-2 sm:mt-3 hidden sm:flex items-center gap-1 text-xs text-text-muted opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<span>查看详情</span>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -234,16 +235,16 @@ function renderInlineFormat(text: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function ScoreBar({ label, value }: { label: string; value: number }) {
|
||||
function ScoreBar({ label, value, weight }: { label: string; value: number; weight?: string }) {
|
||||
const width = Math.min(value, 100);
|
||||
const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between text-[9px] sm:text-[10px] xs:text-xs text-text-muted mb-0.5 sm:mb-1">
|
||||
<span className="font-medium">{label}</span>
|
||||
<div className="flex justify-between text-[10px] text-text-muted mb-1">
|
||||
<span className="font-medium">{label}{weight ? <span className="text-text-muted/30 ml-0.5">{weight}</span> : null}</span>
|
||||
<span className="font-mono tabular-nums">{value.toFixed(0)}</span>
|
||||
</div>
|
||||
<div className="h-1 bg-surface-3 rounded-full overflow-hidden">
|
||||
<div className="h-1.5 bg-surface-3 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-700 ease-out ${gradientClass}`}
|
||||
style={{ width: `${width}%` }}
|
||||
|
||||
@ -7,7 +7,7 @@ interface WSMessage {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function useWebSocket(onMessage?: (data: WSMessage) => void) {
|
||||
export function useWebSocket(onMessage?: (data: WSMessage) => void, messageTypes?: string[]) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimer = useRef<NodeJS.Timeout>();
|
||||
@ -29,6 +29,7 @@ export function useWebSocket(onMessage?: (data: WSMessage) => void) {
|
||||
if (event.data === "pong") return;
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (messageTypes && !messageTypes.includes(data.type)) return;
|
||||
onMessage?.(data);
|
||||
} catch {
|
||||
// ignore
|
||||
@ -42,7 +43,7 @@ export function useWebSocket(onMessage?: (data: WSMessage) => void) {
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
wsRef.current = ws;
|
||||
}, [onMessage]);
|
||||
}, [onMessage, messageTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
@ -102,6 +102,8 @@ export interface RecommendationData {
|
||||
sector_score: number;
|
||||
capital_score: number;
|
||||
technical_score: number;
|
||||
supply_demand_score?: number;
|
||||
price_action_score?: number;
|
||||
position_score?: number;
|
||||
valuation_score?: number;
|
||||
entry_price: number | null;
|
||||
|
||||
34
frontend/src/lib/markdown.ts
Normal file
34
frontend/src/lib/markdown.ts
Normal file
@ -0,0 +1,34 @@
|
||||
export 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");
|
||||
}
|
||||
|
||||
export function formatMarkdown(text: string): string {
|
||||
let html = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/^\s*[-*]\s+(.+)/gm, "<li>$1</li>")
|
||||
.replace(/\n/g, "<br>");
|
||||
// Wrap consecutive <li> items in <ul>
|
||||
html = html.replace(/(<li>.*?<\/li>(<br>)?)+/g, (match) => {
|
||||
return "<ul>" + match.replace(/<br>/g, "") + "</ul>";
|
||||
});
|
||||
return html;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user