This commit is contained in:
aaron 2026-05-14 11:10:17 +08:00
parent bdaaa83bf6
commit d6bae2c8b6
37 changed files with 1866 additions and 1049 deletions

View File

@ -5,14 +5,17 @@
"""
import logging
import asyncio
from app.config import should_prefer_realtime_today, today_trade_date
from app.data.cache import cache
from app.data.eastmoney_client import get_sector_realtime_ranking
from app.data.models import SectorInfo
from app.data.tushare_client import tushare_client
from app.analysis.theme_mapper import merge_sectors_to_themes
logger = logging.getLogger(__name__)
_today_sector_board_tasks: dict[str, asyncio.Task] = {}
def _match_sector_name(em_name: str, ts_name: str) -> bool:
@ -37,11 +40,14 @@ def _apply_empty_overlay(sector: SectorInfo) -> SectorInfo:
sector.data_mode = "daily_snapshot"
sector.source = sector.source or "snapshot"
sector.board_type = sector.board_type or "snapshot"
sector.data_status = "snapshot"
sector.source_detail = "daily_snapshot"
return sector
def _sector_from_eastmoney(item: dict) -> SectorInfo:
"""把东方财富板块榜转换成今日展示用 SectorInfo。"""
source = item.get("source", "eastmoney")
sector = SectorInfo(
sector_code=item.get("sector_code", ""),
sector_name=item.get("sector_name", ""),
@ -62,7 +68,9 @@ def _sector_from_eastmoney(item: dict) -> SectorInfo:
realtime_down_count=int(item.get("down_count", 0) or 0),
is_realtime=True,
data_mode="realtime_today",
source=item.get("source", "eastmoney"),
source=source,
data_status=item.get("data_status", "fresh" if source == "eastmoney" else "fallback"),
source_detail=item.get("source_detail", "eastmoney_push2" if source == "eastmoney" else source),
)
if item.get("leading_stock_name"):
sector.leading_stocks_realtime = [{
@ -77,16 +85,38 @@ def _sector_from_eastmoney(item: dict) -> SectorInfo:
async def get_today_realtime_sector_board(limit: int = 20) -> list[SectorInfo]:
"""用实时行业榜 + 概念榜生成展示列表,作为 Tushare/定时扫描滞后的兜底。"""
industry_sectors = await get_sector_realtime_ranking(fs="m:90+t:2", page_size=max(limit, 20), notify=False)
concept_sectors = await get_sector_realtime_ranking(fs="m:90+t:3", page_size=max(limit, 20), notify=False)
cache_key = f"today_sector_board:{today_trade_date()}:{limit}"
cached = cache.get(cache_key)
if cached is not None:
return cached
task = _today_sector_board_tasks.get(cache_key)
if task and not task.done():
return await task
task = asyncio.create_task(_load_today_realtime_sector_board(limit))
_today_sector_board_tasks[cache_key] = task
try:
result = await task
cache.set(cache_key, result, ttl=60)
return result
finally:
if task.done():
_today_sector_board_tasks.pop(cache_key, None)
async def _load_today_realtime_sector_board(limit: int) -> list[SectorInfo]:
industry_sectors, concept_sectors = await _load_eastmoney_sector_boards(limit=max(limit, 20))
em_sectors = industry_sectors + concept_sectors
if not em_sectors:
if not industry_sectors:
try:
from app.data.sina_client import get_sector_realtime_ranking_by_industry
em_sectors = await get_sector_realtime_ranking_by_industry(limit=max(limit, 20))
sina_sectors = await get_sector_realtime_ranking_by_industry(limit=max(limit, 20))
em_sectors.extend(sina_sectors)
except Exception as e:
logger.warning("新浪行业实时榜兜底失败: %s", e)
em_sectors = []
if not em_sectors:
em_sectors = []
deduped = {}
for item in em_sectors:
@ -100,6 +130,25 @@ async def get_today_realtime_sector_board(limit: int = 20) -> list[SectorInfo]:
return sectors[:limit]
async def _load_eastmoney_sector_boards(limit: int) -> tuple[list[dict], list[dict]]:
"""并行拉取行业/概念榜,允许部分成功。"""
import asyncio
results = await asyncio.gather(
get_sector_realtime_ranking(fs="m:90+t:2", page_size=limit, notify=False),
get_sector_realtime_ranking(fs="m:90+t:3", page_size=limit, notify=False),
return_exceptions=True,
)
boards: list[list[dict]] = []
for label, result in zip(("行业", "概念"), results):
if isinstance(result, Exception):
logger.warning("东方财富%s实时榜失败: %s", label, result)
boards.append([])
else:
boards.append(result)
return boards[0], boards[1]
async def enrich_sectors_with_realtime(sectors: list[SectorInfo]) -> list[SectorInfo]:
"""按需为板块快照追加实时字段并重排。"""
if not sectors:
@ -151,6 +200,8 @@ async def enrich_sectors_with_realtime(sectors: list[SectorInfo]) -> list[Sector
sector.is_realtime = True
sector.data_mode = "realtime_overlay"
sector.source = em_data.get("source", "eastmoney")
sector.data_status = em_data.get("data_status", "fresh")
sector.source_detail = em_data.get("source_detail", sector.source)
logger.info("板块实时覆盖: %s/%s 匹配成功", matched, len(sectors))
sectors.sort(key=lambda s: (s.realtime_pct_change if s.realtime_pct_change is not None else s.pct_change), reverse=True)

View File

@ -159,6 +159,14 @@ def merge_sectors_to_themes(sectors: list[SectorInfo], limit: int = 20) -> list[
existing.is_realtime = existing.is_realtime or sector.is_realtime
if existing.data_mode == "daily_snapshot" and sector.data_mode != "daily_snapshot":
existing.data_mode = sector.data_mode
if existing.data_status != "fresh" and sector.data_status == "fresh":
existing.data_status = "fresh"
existing.source_detail = sector.source_detail
elif existing.data_status == "fresh" and sector.data_status != "fresh":
pass
elif existing.data_status != sector.data_status:
existing.data_status = "mixed"
existing.source_detail = "mixed_status"
if existing.source != sector.source:
existing.source = "mixed"

View File

@ -0,0 +1,26 @@
"""新闻/政策催化 API。"""
from fastapi import APIRouter, Depends
from app.catalyst.models import CatalystInput
from app.catalyst.service import build_theme_catalyst_scores, get_recent_catalysts, ingest_catalyst
from app.core.deps import get_current_admin
router = APIRouter(prefix="/api/catalysts", tags=["catalysts"])
@router.get("/recent")
async def recent(limit: int = 30, hours: int = 72):
return await get_recent_catalysts(limit=limit, hours=hours)
@router.get("/theme-scores")
async def theme_scores(hours: int = 72, limit: int = 20):
scores = await build_theme_catalyst_scores(hours=hours, limit=limit)
return [item.model_dump() for item in scores]
@router.post("/ingest")
async def ingest(item: CatalystInput, _admin: dict = Depends(get_current_admin)):
analysis = await ingest_catalyst(item, use_llm=True)
return analysis.model_dump()

View File

@ -4,13 +4,9 @@ from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from app.data.tushare_client import tushare_client
from app.data import tencent_client
from app.data.cache import cache
from app.data.market_breadth_client import get_market_breadth
from app.analysis.market_temp import build_realtime_market_temperature, calculate_market_temperature
from app.engine.recommender import get_latest_recommendations
from app.config import settings, is_trading_hours, is_market_session, should_prefer_realtime_today, today_trade_date
from app.config import settings, is_trading_hours, should_prefer_realtime_today, today_trade_date
from app.core.deps import get_current_admin
router = APIRouter(prefix="/api/market", tags=["market"])
@ -18,17 +14,9 @@ router = APIRouter(prefix="/api/market", tags=["market"])
@router.get("/temperature")
async def get_temperature():
"""获取市场温度。
交易日 09:15 后优先做轻量实时计算不触发完整扫描或 LLM
"""
"""获取市场温度快照。页面访问只读数据库,不触发外部行情。"""
result = await get_latest_recommendations()
mt = result.get("market_temp")
realtime_used = False
if should_prefer_realtime_today(mt.trade_date if mt else None):
baseline = mt or calculate_market_temperature()
mt, realtime_used = await build_realtime_market_temperature(baseline)
breadth = await get_market_breadth() if realtime_used else None
if mt:
return {
"trade_date": mt.trade_date,
@ -41,8 +29,8 @@ async def get_temperature():
"broken_rate": mt.broken_rate,
"index_above_ma20": getattr(mt, "index_above_ma20", False),
"is_trading": is_trading_hours(),
"data_mode": "realtime_today" if realtime_used else "daily_snapshot",
"limit_counts_reliable": breadth.limit_counts_reliable if breadth else False,
"data_mode": "daily_snapshot",
"limit_counts_reliable": False,
}
return {
"trade_date": "",
@ -60,14 +48,12 @@ async def get_temperature():
@router.get("/overview")
async def get_overview():
"""市场概况:上证、深证、创业板指数
"""市场概况快照。
盘中用腾讯实时行情盘后用 Tushare 日线有缓存
页面访问不拉腾讯/Tushare当前库里还没有指数快照表先返回空数组
后续应由扫描任务把指数概览写入本地表后再展示
"""
latest_trade_date = tushare_client.get_latest_trade_date()
if should_prefer_realtime_today(latest_trade_date):
return await _overview_realtime()
return _overview_daily()
return []
@router.get("/strategy-board")
@ -217,53 +203,3 @@ async def generate_strategy_iteration(limit: int = 50, _admin: dict = Depends(ge
cache.delete(f"market:strategy_iteration:{limit}:rules")
cache.delete("market:strategy_board:rules")
return result
async def _overview_realtime():
"""盘中:腾讯实时指数行情"""
index_data = await tencent_client.get_index_realtime()
result = []
name_map = {
"000001.SH": "上证指数",
"399001.SZ": "深证成指",
"399006.SZ": "创业板指",
}
for code in ["000001.SH", "399001.SZ", "399006.SZ"]:
data = index_data.get(code)
if not data:
continue
result.append({
"name": name_map.get(code, data.get("name", code)),
"code": code,
"close": round(data["price"], 2),
"pct_chg": round(data["pct_chg"], 2),
"volume": round(data["volume"], 2),
"realtime": True,
})
return result
def _overview_daily():
"""盘后Tushare 日线数据"""
indices = {
"上证指数": "000001.SH",
"深证成指": "399001.SZ",
"创业板指": "399006.SZ",
}
result = []
for name, code in indices.items():
df = tushare_client.get_index_daily(code, days=5)
if df.empty:
continue
df = df.sort_values("trade_date")
latest = df.iloc[-1]
prev = df.iloc[-2] if len(df) > 1 else latest
pct = (latest["close"] - prev["close"]) / prev["close"] * 100
result.append({
"name": name,
"code": code,
"close": round(float(latest["close"]), 2),
"pct_chg": round(pct, 2),
"volume": round(float(latest["vol"]), 2),
"realtime": False,
})
return result

View File

@ -13,8 +13,7 @@ from app.engine.recommender import (
get_recommendation_history,
get_performance_stats,
)
from app.config import is_trading_hours, should_prefer_realtime_today
from app.data.tushare_client import tushare_client
from app.config import is_trading_hours
from app.core.deps import get_current_admin
logger = logging.getLogger(__name__)
@ -29,13 +28,8 @@ async def get_latest():
anomalies = await get_latest_market_anomalies()
mt = result.get("market_temp")
try:
from app.api.market import get_temperature
realtime_temp = await get_temperature()
except Exception:
realtime_temp = None
return {
"market_temperature": realtime_temp or ({
"market_temperature": ({
"trade_date": mt.trade_date if mt else "",
"temperature": mt.temperature if mt else 0,
"up_count": mt.up_count if mt else 0,
@ -81,6 +75,7 @@ async def get_latest():
"prefilter_decision": r.prefilter_decision,
"prefilter_reason": r.prefilter_reason,
"focus_points": r.focus_points,
"decision_trace": r.decision_trace,
"strategy": r.strategy,
"entry_signal_type": r.entry_signal_type,
"scan_session": r.scan_session,
@ -163,13 +158,12 @@ async def update_tracking(_admin: dict = Depends(get_current_admin)):
@router.get("/status")
async def get_scan_status():
"""获取当前扫描状态信息"""
latest_trade_date = tushare_client.get_latest_trade_date()
prefer_realtime = should_prefer_realtime_today(latest_trade_date)
"""获取当前扫描状态信息。只根据本地时间判断,不访问外部数据源。"""
prefer_realtime = is_trading_hours()
return {
"is_trading": is_trading_hours(),
"scan_mode": "realtime_today" if prefer_realtime else "post_market",
"description": "今日实时分析优先" if prefer_realtime else "盘后分析Tushare日级数据",
"description": "交易时段,扫描任务会使用实时源" if prefer_realtime else "非交易时段,展示最近扫描结论",
}

View File

@ -2,8 +2,6 @@
from fastapi import APIRouter
from app.analysis.sector_realtime import enrich_sectors_with_realtime, get_today_realtime_sector_board
from app.config import should_prefer_realtime_today, today_trade_date
from app.data.tushare_client import tushare_client
from app.data.cache import cache
from app.engine.recommender import get_latest_sectors
@ -13,17 +11,11 @@ router = APIRouter(prefix="/api/sectors", tags=["sectors"])
@router.get("/hot")
async def get_hot_sectors(limit: int = 10):
"""获取今日主线主题排名(盘中自动补充实时数据并统一归一)"""
"""获取最新主线主题排名。
页面访问只读数据库里的扫描结论不在 GET 请求中拉取外部实时行情
"""
sectors = await get_latest_sectors()
snapshot_trade_date = sectors[0].trade_date if sectors else ""
if should_prefer_realtime_today(snapshot_trade_date) or snapshot_trade_date != today_trade_date():
realtime_sectors = await get_today_realtime_sector_board(limit=max(limit, 20))
if realtime_sectors:
sectors = realtime_sectors
else:
sectors = await enrich_sectors_with_realtime(sectors)
else:
sectors = await enrich_sectors_with_realtime(sectors)
trade_date = sectors[0].trade_date if sectors else ""
sectors_data = [
@ -57,18 +49,38 @@ async def get_hot_sectors(limit: int = 10):
"is_realtime": s.is_realtime,
"data_mode": s.data_mode,
"source": s.source,
"data_status": s.data_status,
"source_detail": s.source_detail,
"catalyst_score": s.catalyst_score,
"catalyst_count": s.catalyst_count,
"catalyst_reasons": s.catalyst_reasons,
}
for s in sectors[:limit]
]
realtime_enabled = any(s.get("is_realtime") for s in sectors_data)
mode = sectors[0].data_mode if realtime_enabled and sectors else "daily_snapshot"
status = _derive_status(sectors_data)
for s in sectors_data:
s["data_mode"] = mode
s["data_status"] = status
s["structure_trade_date"] = trade_date
return sectors_data
def _derive_status(sectors: list[dict]) -> str:
statuses = {str(s.get("data_status") or "fresh") for s in sectors}
if not statuses:
return "snapshot"
if "fresh" in statuses:
return "fresh" if len(statuses) == 1 else "mixed"
if "stale" in statuses:
return "stale"
if "fallback" in statuses:
return "fallback"
return next(iter(statuses))
@router.get("/rotation")
async def get_sector_rotation(days: int = 5):
"""获取近N日板块轮动数据用于热力图"""

View File

@ -109,6 +109,15 @@ async def get_stock_thesis(ts_code: str):
except Exception:
return []
def _safe_json_dict(value: str | None) -> dict:
if not value:
return {}
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
tracking_history = []
for row in tracking_rows:
t = row._mapping
@ -181,6 +190,7 @@ async def get_stock_thesis(ts_code: str):
"prefilter_decision": r.get("prefilter_decision") or "",
"prefilter_reason": r.get("prefilter_reason") or "",
"focus_points": _safe_json_list(r.get("focus_points")),
"decision_trace": _safe_json_dict(r.get("decision_trace")),
"strategy": r["strategy"] or "trend_breakout",
"entry_signal_type": r["entry_signal_type"] or "none",
"entry_timing": r["entry_timing"] or "",

View File

@ -0,0 +1,5 @@
"""新闻/政策催化理解层。
LLM 只在这里做非结构化文本归因不直接决定买卖
"""

View File

@ -0,0 +1,262 @@
"""新闻/政策催化归因。
边界
- LLM 只负责把文本映射到题材提炼催化类型和解释
- 行情资金最终动作仍由规则引擎决定
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timezone
from app.analysis.theme_mapper import THEME_ALIASES, THEME_NAMES, resolve_theme
from app.catalyst.models import CatalystAnalysis, CatalystInput, CatalystTheme
from app.config import settings
logger = logging.getLogger(__name__)
CATALYST_TYPE_KEYWORDS = {
"policy": ["政策", "工信部", "发改委", "国务院", "证监会", "财政部", "规划", "指导意见", "补贴"],
"industry": ["订单", "需求", "涨价", "产能", "景气", "出口", "交付", "装机", "销量"],
"event": ["大会", "发布会", "展会", "会议", "试点", "招标", "中标", "事故"],
"earnings": ["业绩", "净利润", "营收", "预增", "扭亏", "年报", "季报"],
"announcement": ["公告", "重组", "并购", "定增", "回购", "签订合同"],
}
STRENGTH_KEYWORDS = {
18: ["重大", "重磅", "首次", "超预期", "全面", "国家级"],
12: ["政策", "补贴", "涨价", "订单", "中标", "突破"],
8: ["试点", "规划", "发布", "扩产", "合作"],
}
async def analyze_catalyst(item: CatalystInput, use_llm: bool = True) -> CatalystAnalysis:
"""分析单条催化文本LLM 不可用时使用规则归因。"""
rule_result = _analyze_by_rules(item)
if not use_llm or not settings.deepseek_api_key:
return rule_result
llm_result = await _analyze_by_llm(item, rule_result)
return llm_result or rule_result
def _analyze_by_rules(item: CatalystInput) -> CatalystAnalysis:
text = f"{item.title}\n{item.content}".strip()
themes = _match_themes(text)
catalyst_type = _infer_catalyst_type(text)
strength = _score_strength(text, themes, catalyst_type)
freshness = _score_freshness(item.published_at)
confidence = 45 + min(len(themes) * 12, 35)
if catalyst_type in {"policy", "announcement"}:
confidence += 8
return CatalystAnalysis(
title=item.title,
summary=_summarize_text(item.content or item.title),
source=item.source,
url=item.url,
published_at=item.published_at,
catalyst_type=catalyst_type,
strength=min(strength, 100),
freshness=freshness,
confidence=min(confidence, 90),
themes=themes,
raw_text=text,
generated_by="rules",
)
async def _analyze_by_llm(item: CatalystInput, baseline: CatalystAnalysis) -> CatalystAnalysis | None:
from app.llm.client import get_client
client = get_client()
if not client:
return None
aliases_text = "\n".join(
f"- {THEME_NAMES[theme_id]}: {', '.join(aliases[:8])}"
for theme_id, aliases in THEME_ALIASES.items()
)
user_text = f"""\
请把下面新闻/政策/公告归因到 A 股题材只做语义归因不给买卖建议
## 可选系统题材
{aliases_text}
## 文本
标题: {item.title}
来源: {item.source}
正文: {(item.content or '')[:1600]}
请严格输出 JSON
{{
"summary": "一句话摘要",
"catalyst_type": "policy | industry | event | earnings | announcement | news",
"strength": 0-100,
"confidence": 0-100,
"themes": [
{{"theme_name": "系统题材名或新题材名", "relevance": 0-100, "reason": "一句话"}}
],
"reason": "为什么这么归因"
}}"""
try:
response = await client.chat.completions.create(
model=settings.deepseek_model,
messages=[
{
"role": "system",
"content": (
"你是A股新闻催化归因器。"
"你只能做题材归因、催化类型和强度判断,不能输出买入卖出建议。"
"必须返回合法JSON。"
),
},
{"role": "user", "content": user_text},
],
max_tokens=700,
temperature=0.1,
)
data = _extract_json(response.choices[0].message.content or "")
if not data:
return None
themes = []
for raw_theme in data.get("themes", [])[:5]:
theme_name = str(raw_theme.get("theme_name", "")).strip()
if not theme_name:
continue
theme_id, resolved_name, _ = resolve_theme(theme_name)
themes.append(CatalystTheme(
theme_id=theme_id,
theme_name=resolved_name,
relevance=_clamp_float(raw_theme.get("relevance"), 0, 100, 60),
reason=str(raw_theme.get("reason", "")).strip(),
))
if not themes:
themes = baseline.themes
return CatalystAnalysis(
title=item.title,
summary=str(data.get("summary", "")).strip() or baseline.summary,
source=item.source,
url=item.url,
published_at=item.published_at,
catalyst_type=_normalize_type(data.get("catalyst_type")) or baseline.catalyst_type,
strength=_clamp_float(data.get("strength"), 0, 100, baseline.strength),
freshness=baseline.freshness,
confidence=_clamp_float(data.get("confidence"), 0, 100, baseline.confidence),
themes=themes,
raw_text=baseline.raw_text,
llm_reason=str(data.get("reason", "")).strip(),
generated_by="llm",
)
except Exception as e:
logger.warning("LLM 催化归因失败: %s", e)
return None
def _match_themes(text: str) -> list[CatalystTheme]:
clean_text = _clean(text)
matched: list[CatalystTheme] = []
for theme_id, aliases in THEME_ALIASES.items():
hits = []
for alias in aliases:
alias_clean = _clean(alias)
if alias_clean and alias_clean in clean_text:
hits.append(alias)
if not hits:
continue
relevance = min(55 + len(hits) * 12, 95)
matched.append(CatalystTheme(
theme_id=theme_id,
theme_name=THEME_NAMES[theme_id],
relevance=relevance,
reason=f"命中关键词: {'/'.join(hits[:3])}",
))
matched.sort(key=lambda item: item.relevance, reverse=True)
return matched[:5]
def _infer_catalyst_type(text: str) -> str:
for catalyst_type, keywords in CATALYST_TYPE_KEYWORDS.items():
if any(keyword in text for keyword in keywords):
return catalyst_type
return "news"
def _score_strength(text: str, themes: list[CatalystTheme], catalyst_type: str) -> float:
score = 35.0
if themes:
score += min(max(theme.relevance for theme in themes) * 0.25, 25)
for bonus, keywords in STRENGTH_KEYWORDS.items():
if any(keyword in text for keyword in keywords):
score += bonus
if catalyst_type == "policy":
score += 10
elif catalyst_type == "announcement":
score += 6
return round(min(score, 100), 1)
def _score_freshness(published_at: datetime | None) -> float:
if not published_at:
return 70
dt = published_at
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
hours = max((datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() / 3600, 0)
if hours <= 6:
return 100
if hours <= 24:
return 90
if hours <= 72:
return 70
if hours <= 168:
return 45
return 20
def _summarize_text(text: str) -> str:
value = re.sub(r"\s+", " ", text or "").strip()
return value[:120]
def _clean(value: str) -> str:
return re.sub(r"[\s_\-()【】\[\]、,。:]+", "", value or "")
def _extract_json(text: str) -> dict:
text = (text or "").strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?", "", text).strip()
text = re.sub(r"```$", "", text).strip()
try:
return json.loads(text)
except Exception:
pass
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
try:
return json.loads(text[start:end + 1])
except Exception:
return {}
return {}
def _normalize_type(value) -> str:
text = str(value or "").strip().lower()
return text if text in {"policy", "industry", "event", "earnings", "announcement", "news"} else ""
def _clamp_float(value, minimum: float, maximum: float, default: float) -> float:
try:
num = float(value)
except (TypeError, ValueError):
return default
return max(minimum, min(maximum, num))

View File

@ -0,0 +1,44 @@
"""催化事件领域模型。"""
from datetime import datetime
from pydantic import BaseModel, Field
class CatalystTheme(BaseModel):
theme_id: str
theme_name: str
relevance: float = Field(default=0, ge=0, le=100)
reason: str = ""
class CatalystInput(BaseModel):
title: str
content: str = ""
source: str = "manual"
url: str = ""
published_at: datetime | None = None
class CatalystAnalysis(BaseModel):
title: str
summary: str = ""
source: str = "manual"
url: str = ""
published_at: datetime | None = None
catalyst_type: str = "news"
strength: float = Field(default=0, ge=0, le=100)
freshness: float = Field(default=0, ge=0, le=100)
confidence: float = Field(default=0, ge=0, le=100)
themes: list[CatalystTheme] = []
raw_text: str = ""
llm_reason: str = ""
generated_by: str = "rules"
class ThemeCatalystScore(BaseModel):
theme_id: str
theme_name: str
catalyst_score: float = 0
catalyst_count: int = 0
top_reasons: list[str] = []
generated_by: str = "rules"

View File

@ -0,0 +1,120 @@
"""催化事件存储与主题分数聚合。"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from sqlalchemy import text
from app.catalyst.mapper import analyze_catalyst
from app.catalyst.models import CatalystAnalysis, CatalystInput, ThemeCatalystScore
from app.db import tables
from app.db.database import get_db
logger = logging.getLogger(__name__)
async def ingest_catalyst(item: CatalystInput, use_llm: bool = True) -> CatalystAnalysis:
analysis = await analyze_catalyst(item, use_llm=use_llm)
await save_catalyst(analysis)
return analysis
async def save_catalyst(analysis: CatalystAnalysis) -> int:
async with get_db() as db:
result = await db.execute(
tables.catalysts_table.insert().values(
title=analysis.title,
summary=analysis.summary,
source=analysis.source,
url=analysis.url,
published_at=analysis.published_at,
catalyst_type=analysis.catalyst_type,
strength=analysis.strength,
freshness=analysis.freshness,
confidence=analysis.confidence,
raw_text=analysis.raw_text,
llm_reason=analysis.llm_reason,
is_active=True,
)
)
catalyst_id = int(result.inserted_primary_key[0])
if analysis.themes:
await db.execute(
tables.theme_catalysts_table.insert(),
[
{
"catalyst_id": catalyst_id,
"theme_id": theme.theme_id,
"theme_name": theme.theme_name,
"relevance": theme.relevance,
"reason": theme.reason,
}
for theme in analysis.themes
],
)
await db.commit()
return catalyst_id
async def get_recent_catalysts(limit: int = 30, hours: int = 72) -> list[dict]:
since = datetime.now() - timedelta(hours=hours)
async with get_db() as db:
result = await db.execute(
text(
"SELECT c.*, "
"GROUP_CONCAT(tc.theme_name || ':' || ROUND(tc.relevance, 0), ',') AS themes "
"FROM catalysts c "
"LEFT JOIN theme_catalysts tc ON tc.catalyst_id = c.id "
"WHERE c.is_active = 1 AND COALESCE(c.published_at, c.created_at) >= :since "
"GROUP BY c.id "
"ORDER BY COALESCE(c.published_at, c.created_at) DESC, c.id DESC "
"LIMIT :limit"
),
{"since": since, "limit": limit},
)
rows = result.mappings().all()
return [dict(row) for row in rows]
async def build_theme_catalyst_scores(hours: int = 72, limit: int = 20) -> list[ThemeCatalystScore]:
since = datetime.now() - timedelta(hours=hours)
async with get_db() as db:
rows = (
await db.execute(
text(
"SELECT tc.theme_id, tc.theme_name, "
"COUNT(*) AS catalyst_count, "
"SUM((c.strength * 0.45 + c.freshness * 0.25 + c.confidence * 0.15 + tc.relevance * 0.15)) AS raw_score, "
"GROUP_CONCAT(SUBSTR(COALESCE(tc.reason, c.summary, c.title), 1, 60), ' | ') AS reasons "
"FROM theme_catalysts tc "
"JOIN catalysts c ON c.id = tc.catalyst_id "
"WHERE c.is_active = 1 AND COALESCE(c.published_at, c.created_at) >= :since "
"GROUP BY tc.theme_id, tc.theme_name "
"ORDER BY raw_score DESC "
"LIMIT :limit"
),
{"since": since, "limit": limit},
)
).mappings().all()
scores = []
for row in rows:
raw = float(row.get("raw_score") or 0)
count = int(row.get("catalyst_count") or 0)
normalized = min(raw / max(count, 1), 100)
reasons = [
item.strip()
for item in str(row.get("reasons") or "").split("|")
if item.strip()
][:3]
scores.append(ThemeCatalystScore(
theme_id=row["theme_id"],
theme_name=row["theme_name"],
catalyst_score=round(normalized, 1),
catalyst_count=count,
top_reasons=reasons,
generated_by="catalyst_layer",
))
return scores

View File

@ -8,6 +8,9 @@
"""
import logging
import random
import time
import asyncio
import httpx
import pandas as pd
from datetime import datetime
@ -29,6 +32,11 @@ SECTOR_HEADERS = {
"Referer": "https://data.eastmoney.com",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
}
SECTOR_RETRY_COUNT = 2
SECTOR_RETRY_BASE_DELAY = 0.35
SECTOR_CIRCUIT_BREAKER_SECONDS = 180
_sector_failure_state: dict[str, dict] = {}
_ashare_realtime_tasks: dict[str, asyncio.Task] = {}
def _describe_exception(exc: Exception) -> str:
@ -38,6 +46,16 @@ def _describe_exception(exc: Exception) -> str:
return exc.__class__.__name__
def _mark_rows_status(rows: list[dict], status: str, detail: str) -> list[dict]:
result = []
for row in rows:
item = dict(row)
item["data_status"] = status
item["source_detail"] = detail
result.append(item)
return result
def _ts_code_to_eastmoney(ts_code: str) -> str:
"""600519.SH -> 1.600519 (上海=1, 深圳=0)"""
code, market = ts_code.split(".")
@ -84,6 +102,21 @@ async def get_sector_realtime_ranking(
if cached is not None:
return cached
stale_cache_key = f"{cache_key}:last_success"
circuit_key = f"{fs}:{sort_by}"
failure_state = _sector_failure_state.get(circuit_key, {})
circuit_until = float(failure_state.get("circuit_until", 0) or 0)
if time.time() < circuit_until:
stale = cache.get(stale_cache_key)
if stale is not None:
logger.warning(
"东方财富板块实时排名熔断中,返回最近成功快照: fs=%s sort_by=%s",
fs,
sort_by,
)
return _mark_rows_status(stale, status="stale", detail="eastmoney_circuit_open")
return []
params = {
"pn": "1",
"pz": str(page_size),
@ -99,18 +132,38 @@ async def get_sector_realtime_ranking(
"fields": "f2,f3,f4,f6,f8,f12,f14,f104,f105,f128,f140,f141",
}
try:
board_type = "industry" if fs == "m:90+t:2" else "concept" if fs == "m:90+t:3" else "region" if fs == "m:90+t:1" else "unknown"
async with httpx.AsyncClient() as client:
resp = await client.get(
SECTOR_LIST_URL,
params=params,
headers=SECTOR_HEADERS,
timeout=10,
follow_redirects=True,
board_type = "industry" if fs == "m:90+t:2" else "concept" if fs == "m:90+t:3" else "region" if fs == "m:90+t:1" else "unknown"
last_error: Exception | None = None
data: dict = {}
for attempt in range(SECTOR_RETRY_COUNT + 1):
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
SECTOR_LIST_URL,
params=params,
headers=SECTOR_HEADERS,
timeout=10,
follow_redirects=True,
)
data = _parse_eastmoney_json(resp, "板块实时排名")
break
except Exception as e:
last_error = e
if attempt >= SECTOR_RETRY_COUNT:
data = {}
break
delay = SECTOR_RETRY_BASE_DELAY * (2 ** attempt) + random.uniform(0, 0.2)
logger.warning(
"东方财富板块实时排名获取失败,准备重试 %s/%s: %s",
attempt + 1,
SECTOR_RETRY_COUNT,
_describe_exception(e),
)
data = _parse_eastmoney_json(resp, "板块实时排名")
await asyncio.sleep(delay)
try:
if last_error and not data:
raise last_error
items = data.get("data", {}).get("diff", [])
if not items:
logger.debug("东方财富板块实时排名无数据")
@ -134,21 +187,45 @@ async def get_sector_realtime_ranking(
"leading_stock_name": item.get("f128", ""),
"leading_stock_code": item.get("f140", ""),
"leading_stock_pct": float(item.get("f141", 0) or 0),
"source": "eastmoney",
"data_status": "fresh",
"source_detail": "eastmoney_push2",
})
# 缓存:盘中 60 秒,盘后 300 秒
ttl = 60 if _is_trading_hours() else 300
cache.set(cache_key, result, ttl)
cache.set(stale_cache_key, result, 60 * 60 * 6)
_sector_failure_state.pop(circuit_key, None)
logger.info(f"东方财富板块实时排名: 获取 {len(result)} 个板块")
return result
except Exception as e:
logger.error(f"东方财富板块实时排名获取失败: {e}")
if notify:
failure_count = int(failure_state.get("failure_count", 0) or 0) + 1
_sector_failure_state[circuit_key] = {
"failure_count": failure_count,
"circuit_until": time.time() + SECTOR_CIRCUIT_BREAKER_SECONDS,
"last_error": _describe_exception(e),
}
stale = cache.get(stale_cache_key)
if stale is not None:
logger.warning("东方财富板块实时排名获取失败,返回最近成功快照: %s", _describe_exception(e))
if notify and failure_count >= 3:
await log_error(
"eastmoney",
f"东方财富板块实时排名连续失败,已使用最近成功快照: {_describe_exception(e)}",
detail=f"fs={fs}, sort_by={sort_by}, page_size={page_size}, failure_count={failure_count}",
level="warning",
notify=False,
)
return _mark_rows_status(stale, status="stale", detail="eastmoney_last_success")
logger.error(f"东方财富板块实时排名获取失败且无可用快照: {e}")
if notify and failure_count >= 2:
await log_error(
"eastmoney",
f"东方财富板块实时排名获取失败: {e}",
detail=f"fs={fs}, sort_by={sort_by}, page_size={page_size}",
detail=f"fs={fs}, sort_by={sort_by}, page_size={page_size}, failure_count={failure_count}",
)
return []
@ -164,6 +241,31 @@ async def get_a_share_realtime_ranking(
if cached is not None:
return cached
task = _ashare_realtime_tasks.get(cache_key)
if task and not task.done():
return await task
task = asyncio.create_task(_load_a_share_realtime_ranking(
cache_key=cache_key,
sort_by=sort_by,
descending=descending,
page_size=page_size,
))
_ashare_realtime_tasks[cache_key] = task
try:
return await task
finally:
if task.done():
_ashare_realtime_tasks.pop(cache_key, None)
async def _load_a_share_realtime_ranking(
cache_key: str,
sort_by: str,
descending: bool,
page_size: int,
) -> list[dict]:
fs = "m:0+t:6,m:0+t:80,m:0+t:81+s:2048,m:1+t:2,m:1+t:23"
fields = "f2,f3,f6,f8,f9,f12,f14,f20,f21,f23,f62"

View File

@ -11,6 +11,7 @@
from __future__ import annotations
import logging
import asyncio
import httpx
@ -25,15 +26,29 @@ logger = logging.getLogger(__name__)
ZT_POOL_URL = "https://push2ex.eastmoney.com/getTopicZTPool"
DT_POOL_URL = "https://push2ex.eastmoney.com/getTopicDTPool"
MIN_RELIABLE_SAMPLE_COUNT = 4500
_market_breadth_task: asyncio.Task | None = None
async def get_market_breadth() -> MarketBreadth:
"""获取市场广度快照。"""
global _market_breadth_task
cache_key = f"market_breadth:{today_trade_date()}"
cached = cache.get(cache_key)
if cached is not None:
return cached
if _market_breadth_task and not _market_breadth_task.done():
return await _market_breadth_task
_market_breadth_task = asyncio.create_task(_load_market_breadth(cache_key))
try:
return await _market_breadth_task
finally:
if _market_breadth_task.done():
_market_breadth_task = None
async def _load_market_breadth(cache_key: str) -> MarketBreadth:
quotes = await get_a_share_realtime_ranking(page_size=6000)
if quotes and len(quotes) >= MIN_RELIABLE_SAMPLE_COUNT:
up_count = sum(1 for q in quotes if q.get("pct_chg", 0) > 0)

View File

@ -85,6 +85,11 @@ class SectorInfo(BaseModel):
is_realtime: bool = False
data_mode: str = "daily_snapshot"
source: str = "snapshot"
data_status: str = "fresh" # fresh / stale / fallback / snapshot
source_detail: str = ""
catalyst_score: float = 0
catalyst_count: int = 0
catalyst_reasons: list[str] = []
class MarketTemperature(BaseModel):
@ -175,6 +180,7 @@ class Recommendation(BaseModel):
prefilter_decision: str = ""
prefilter_reason: str = ""
focus_points: list[str] = []
decision_trace: dict = {}
scan_session: str = ""
created_at: datetime | None = None

View File

@ -59,6 +59,7 @@ async def init_db():
"ALTER TABLE recommendations ADD COLUMN prefilter_decision TEXT DEFAULT ''",
"ALTER TABLE recommendations ADD COLUMN prefilter_reason TEXT DEFAULT ''",
"ALTER TABLE recommendations ADD COLUMN focus_points TEXT DEFAULT '[]'",
"ALTER TABLE recommendations ADD COLUMN decision_trace TEXT DEFAULT '{}'",
"ALTER TABLE sector_heat ADD COLUMN stage TEXT",
"ALTER TABLE sector_heat ADD COLUMN board_type TEXT DEFAULT 'theme'",
"ALTER TABLE sector_heat ADD COLUMN theme_id TEXT DEFAULT ''",
@ -97,6 +98,7 @@ async def init_db():
"ALTER TABLE strategy_configs ADD COLUMN effective_from DATETIME DEFAULT CURRENT_TIMESTAMP",
"ALTER TABLE prompt_configs ADD COLUMN evidence_json TEXT DEFAULT '{}'",
"ALTER TABLE strategy_config_changes ADD COLUMN prompt_key TEXT DEFAULT ''",
"ALTER TABLE catalysts ADD COLUMN llm_reason TEXT DEFAULT ''",
]:
try:
await conn.execute(

View File

@ -44,6 +44,7 @@ recommendations_table = Table(
Column("prefilter_decision", Text, default=""),
Column("prefilter_reason", Text, default=""),
Column("focus_points", Text, default="[]"),
Column("decision_trace", Text, default="{}"),
Column("scan_session", Text),
Column("created_at", DateTime, server_default=func.now()),
)
@ -238,3 +239,32 @@ strategy_config_changes_table = Table(
Column("created_at", DateTime, server_default=func.now()),
Column("applied_at", DateTime),
)
catalysts_table = Table(
"catalysts", metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("title", Text, nullable=False),
Column("summary", Text, default=""),
Column("source", Text, default="manual"),
Column("url", Text, default=""),
Column("published_at", DateTime),
Column("catalyst_type", Text, default="news"),
Column("strength", Float, default=0),
Column("freshness", Float, default=0),
Column("confidence", Float, default=0),
Column("is_active", Boolean, default=True),
Column("raw_text", Text, default=""),
Column("llm_reason", Text, default=""),
Column("created_at", DateTime, server_default=func.now()),
)
theme_catalysts_table = Table(
"theme_catalysts", metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("catalyst_id", Integer, nullable=False),
Column("theme_id", Text, nullable=False),
Column("theme_name", Text, nullable=False),
Column("relevance", Float, default=0),
Column("reason", Text, default=""),
Column("created_at", DateTime, server_default=func.now()),
)

View File

@ -36,6 +36,55 @@ def _has_valid_market_breadth(market_temp: MarketTemperature | None) -> bool:
return (market_temp.up_count or 0) + (market_temp.down_count or 0) > 0
def _safe_json_dict(value) -> dict:
if not value:
return {}
if isinstance(value, dict):
return value
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
def _safe_json_list_value(value) -> list:
if not value:
return []
if isinstance(value, list):
return value
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, list) else []
except Exception:
return []
def _build_legacy_decision_trace(row) -> dict:
r = row._mapping if hasattr(row, "_mapping") else row
action_plan = r.get("action_plan") or "观察"
score = float(r.get("score") or 0)
tags = _safe_json_list_value(r.get("recall_tags"))
reasons = _safe_json_list_value(r.get("reasons"))
return {
"version": 0,
"headline": f"{action_plan}: {r.get('sector') or '未归类'}候选,综合分{score:.0f}",
"action_plan": action_plan,
"final_score": round(score, 1),
"route_tags": tags,
"evidence": reasons[:3],
"score_breakdown": [
{"key": "sector", "label": "主题热度", "score": round(float(r.get("sector_score") or 0), 1), "weight": 0},
{"key": "capital", "label": "资金", "score": round(float(r.get("capital_score") or 0), 1), "weight": 0},
{"key": "technical", "label": "技术", "score": round(float(r.get("technical_score") or 0), 1), "weight": 0},
],
"boosts": [],
"penalties": [],
"risk_tags": [],
"llm_adjustment": None,
}
async def refresh_recommendations(trade_date: str = None, scan_session: str = "manual") -> dict:
"""刷新推荐列表(带扫描锁防止并发)"""
global _scan_running
@ -594,6 +643,7 @@ async def get_recommendation_history(days: int = 7) -> list[dict]:
"prefilter_decision": r.get("prefilter_decision") or "",
"prefilter_reason": r.get("prefilter_reason") or "",
"focus_points": json.loads(r.get("focus_points") or "[]"),
"decision_trace": _safe_json_dict(r.get("decision_trace")) or _build_legacy_decision_trace(r),
"tracking": {
"current_price": r.get("latest_current_price"),
"pct_from_entry": r.get("latest_pct_from_entry"),
@ -812,6 +862,7 @@ async def _save_to_db(result: dict):
"prefilter_decision": rec.prefilter_decision,
"prefilter_reason": rec.prefilter_reason,
"focus_points": json.dumps(rec.focus_points, ensure_ascii=False),
"decision_trace": json.dumps(rec.decision_trace, ensure_ascii=False),
"scan_session": rec.scan_session,
"created_at": now_dt,
}
@ -916,6 +967,7 @@ async def _load_today_from_db() -> dict:
prefilter_decision=r.get("prefilter_decision") or "",
prefilter_reason=r.get("prefilter_reason") or "",
focus_points=json.loads(r.get("focus_points") or "[]"),
decision_trace=_safe_json_dict(r.get("decision_trace")) or _build_legacy_decision_trace(r),
scan_session=r["scan_session"] or "",
))

View File

@ -40,6 +40,7 @@ from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Reco
from app.config import settings, should_prefer_realtime_today
from app.data.tushare_client import tushare_client
from app.llm.strategy_selector import StrategyProfile, select_strategy_profile
from app.catalyst.service import build_theme_catalyst_scores
logger = logging.getLogger(__name__)
@ -96,6 +97,8 @@ async def run_screening(trade_date: str = None) -> dict:
logger.info("无合格主线主题(需要资金/实时强度+非尾声),回退到全部主题")
hot_sectors = all_themes[:settings.top_sector_count]
hot_sectors = await _apply_catalyst_scores(hot_sectors)
for s in hot_sectors:
logger.info(f" 目标主题: {s.sector_name} 涨幅{s.pct_change}% 资金{s.capital_inflow:.0f}"
f"涨停{s.limit_up_count} 阶段={s.stage}")
@ -187,6 +190,38 @@ async def run_screening(trade_date: str = None) -> dict:
}
async def _apply_catalyst_scores(sectors: list[SectorInfo]) -> list[SectorInfo]:
if not sectors:
return sectors
try:
scores = await build_theme_catalyst_scores(hours=72, limit=50)
except Exception as e:
logger.warning("催化分数加载失败,跳过主题催化加权: %s", e)
return sectors
if not scores:
return sectors
score_map = {item.theme_id: item for item in scores}
name_map = {item.theme_name: item for item in scores}
for sector in sectors:
item = score_map.get(sector.theme_id) or name_map.get(sector.sector_name)
if not item:
continue
sector.catalyst_score = item.catalyst_score
sector.catalyst_count = item.catalyst_count
sector.catalyst_reasons = item.top_reasons
# 催化只增强主线优先级,不替代资金确认。
sector.heat_score = round(min(sector.heat_score + item.catalyst_score * 0.18, 100), 1)
sectors.sort(
key=lambda s: (
s.heat_score,
s.catalyst_score,
s.realtime_pct_change if s.realtime_pct_change is not None else s.pct_change,
),
reverse=True,
)
return sectors
async def _select_from_hot_sectors(
hot_sectors: list[SectorInfo],
trade_date: str,
@ -605,10 +640,11 @@ async def _build_recommendations(
total = len(candidates)
signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0}
score_weights = strategy_profile.score_weights if strategy_profile else {
"capital_momentum": 0.30,
"supply_demand": 0.30,
"price_action": 0.25,
"trend": 0.15,
"catalyst": 0.30,
"theme_money": 0.25,
"stock_money": 0.20,
"emotion_role": 0.15,
"timing": 0.10,
}
score_weights = _normalize_score_weights(score_weights)
signal_priority = strategy_profile.entry_signal_priority if strategy_profile else []
@ -649,12 +685,32 @@ async def _build_recommendations(
signal_name = signal_type.value
signal_counts[signal_name] += 1
# ── 三维度评分 ──
# ── 五轴评分:催化、主题资金、个股资金、情绪角色、入场时机 ──
supply_demand_score = score_supply_demand(df)
price_action_score = _score_price_action(df, entry_signal)
trend_score = _score_trend(df)
capital_score = _score_capital_simple(stock)
flow_momentum_score = _score_flow_momentum(stock, sector, hot_sectors)
sector_stage = _get_sector_stage(sector, hot_sectors)
hot_theme_match = find_hot_theme_match(sector, hot_sectors)
sector_limit_up = _get_sector_limit_up(sector, hot_sectors)
catalyst_score = _get_sector_catalyst_score(sector, hot_sectors)
catalyst_reasons = _get_sector_catalyst_reasons(sector, hot_sectors)
theme_money_score = _score_theme_money(sector, hot_sectors, hot_theme_match)
stock_money_score = _score_stock_money(stock, capital_score)
emotion_role_score = _score_emotion_role(
stock=stock,
sector_limit_up=sector_limit_up,
sector_stage=sector_stage,
hot_theme_match=hot_theme_match,
hot_sectors=hot_sectors,
)
timing_score = _score_timing(
entry_signal_score=entry_signal.get("signal_score", 0),
price_action_score=price_action_score,
trend_score=trend_score,
position_score=50,
)
last = df.iloc[-1]
trend_penalty = 1.0
@ -663,15 +719,28 @@ async def _build_recommendations(
if last["ma5"] < last["ma10"] < last["ma20"]:
trend_penalty = 0.82
final_score = (
flow_momentum_score * score_weights["capital_momentum"] +
supply_demand_score * score_weights["supply_demand"] +
price_action_score * score_weights["price_action"] +
trend_score * score_weights["trend"]
)
scoring_axes = {
"catalyst": catalyst_score,
"theme_money": theme_money_score,
"stock_money": stock_money_score,
"emotion_role": emotion_role_score,
"timing": timing_score,
}
final_score = sum(scoring_axes[key] * score_weights[key] for key in scoring_axes)
final_score *= trend_penalty
tech_signal = generate_signals(ts_code, name)
if tech_signal:
timing_score = _score_timing(
entry_signal_score=entry_signal.get("signal_score", 0),
price_action_score=price_action_score,
trend_score=trend_score,
position_score=tech_signal.position_score,
)
scoring_axes["timing"] = timing_score
final_score = sum(scoring_axes[key] * score_weights[key] for key in scoring_axes)
final_score *= trend_penalty
penalties = []
if tech_signal:
if tech_signal.rally_pct_5d > 20:
@ -679,9 +748,6 @@ async def _build_recommendations(
elif tech_signal.rally_pct_5d > 15:
penalties.append(0.80)
sector_stage = _get_sector_stage(sector, hot_sectors)
hot_theme_match = find_hot_theme_match(sector, hot_sectors)
if sector_stage == "end":
penalties.append(0.70)
elif sector_stage == "late":
@ -695,31 +761,59 @@ async def _build_recommendations(
if penalties:
final_score *= min(penalties)
sector_limit_up = _get_sector_limit_up(sector, hot_sectors)
boosts = []
if sector_limit_up >= 5:
final_score *= 1.20
boosts.append({"label": "板块涨停扩散", "value": "+20%", "reason": f"{sector_limit_up}家涨停"})
elif sector_limit_up >= 3:
final_score *= 1.10
boosts.append({"label": "板块涨停扩散", "value": "+10%", "reason": f"{sector_limit_up}家涨停"})
if entry_signal.get("signal_score", 0) >= 80:
final_score *= 1.10
boosts.append({"label": "入场形态强", "value": "+10%", "reason": f"信号分{entry_signal.get('signal_score', 0):.0f}"})
final_score *= _flow_confirmation_multiplier(stock, hot_theme_match, market_temp)
if catalyst_score >= 70 and hot_theme_match:
final_score *= 1.06
boosts.append({"label": "新闻催化确认", "value": "+6%", "reason": catalyst_reasons[0] if catalyst_reasons else f"催化分{catalyst_score:.0f}"})
elif catalyst_score >= 45 and hot_theme_match:
final_score *= 1.03
boosts.append({"label": "新闻催化加权", "value": "+3%", "reason": catalyst_reasons[0] if catalyst_reasons else f"催化分{catalyst_score:.0f}"})
flow_multiplier = _flow_confirmation_multiplier(stock, hot_theme_match, market_temp)
final_score *= flow_multiplier
if flow_multiplier > 1:
boosts.append({"label": "资金主线共振", "value": f"+{round((flow_multiplier - 1) * 100)}%", "reason": "资金、量能与主线同向"})
elif flow_multiplier < 1:
boosts.append({"label": "资金确认不足", "value": f"-{round((1 - flow_multiplier) * 100)}%", "reason": "资金或主线承接不足"})
theme_penalty = 1.0
if not hot_theme_match:
final_score *= 0.82
theme_penalty = 0.82
final_score *= theme_penalty
elif hot_theme_match not in hot_sectors[:5]:
final_score *= 0.9
theme_penalty = 0.9
final_score *= theme_penalty
signal_matches_profile = bool(signal_priority and signal_name in signal_priority[:4])
profile_multiplier = 1.0
if signal_type != EntrySignal.NONE and signal_priority:
priority_rank = signal_priority.index(signal_type.value)
if priority_rank == 0:
final_score *= 1.08
profile_multiplier = 1.08
final_score *= profile_multiplier
elif priority_rank == 1:
final_score *= 1.04
profile_multiplier = 1.04
final_score *= profile_multiplier
elif priority_rank >= 3:
final_score *= 0.94
profile_multiplier = 0.94
final_score *= profile_multiplier
if profile_multiplier != 1.0:
boosts.append({
"label": "策略匹配度",
"value": f"{'+' if profile_multiplier > 1 else '-'}{round(abs(profile_multiplier - 1) * 100)}%",
"reason": f"{signal_name} 与今日策略优先级匹配",
})
pe = stock.get("pe")
pb = stock.get("pb")
@ -833,6 +927,41 @@ async def _build_recommendations(
entry_timing=entry_timing,
data_date=last_date,
)
risk_tags = _build_risk_tags(market_temp, tech_signal, sector_stage, trend_penalty)
penalty_notes = _build_penalty_notes(
penalties=penalties,
trend_penalty=trend_penalty,
theme_penalty=theme_penalty,
market_temp_score=market_temp_score,
sector_stage=sector_stage,
hot_theme_match=hot_theme_match,
)
decision_trace = _build_decision_trace(
stock=stock,
score=final_score,
score_weights=score_weights,
scoring_axes=scoring_axes,
flow_momentum_score=flow_momentum_score,
supply_demand_score=supply_demand_score,
price_action_score=price_action_score,
trend_score=trend_score,
capital_score=capital_score,
position_score=position_score,
valuation_score=valuation_score,
entry_signal_type=effective_signal_name,
entry_signal_score=entry_signal.get("signal_score", 0),
signal_matches_profile=signal_matches_profile,
sector_stage=sector_stage,
sector_limit_up=sector_limit_up,
catalyst_score=catalyst_score,
catalyst_reasons=catalyst_reasons,
market_temp=market_temp,
trade_plan=trade_plan,
boosts=boosts,
penalties=penalty_notes,
risk_tags=risk_tags,
hot_theme_match=hot_theme_match,
)
rec = Recommendation(
ts_code=ts_code,
@ -868,6 +997,7 @@ async def _build_recommendations(
prefilter_decision="",
prefilter_reason="",
focus_points=[],
decision_trace=decision_trace,
)
recommendations.append(rec)
@ -894,7 +1024,7 @@ async def _build_recommendations(
"entry_signal_score": round(entry_signal.get("signal_score", 0), 1),
"flow_momentum_score": round(flow_momentum_score, 1),
"signal_matches_profile": signal_matches_profile,
"risk_tags": _build_risk_tags(market_temp, tech_signal, sector_stage, trend_penalty),
"risk_tags": risk_tags,
"focus_points": _build_focus_points(stock, entry_signal, tech_signal, vol_pattern, sector_stage),
}
@ -1035,6 +1165,13 @@ async def _build_recommendations(
rec.risk_note = llm_data["risk_flag"]
rec.level = _score_to_level(rec.score)
_apply_llm_trace(
rec,
verdict=verdict,
action_plan=rec.action_plan,
conviction=conviction,
reason=llm_data.get("analysis", "") or llm_data.get("risk_flag", ""),
)
# 用 LLM 给出的价格替代结构化规则价格
if llm_data.get("entry_price"):
@ -1068,21 +1205,188 @@ async def _build_recommendations(
def _normalize_score_weights(weights: dict[str, float]) -> dict[str, float]:
"""兼容旧策略权重,并保证资金顺势进入主评分"""
"""归一化五轴主评分权重,并兼容旧四项策略配置"""
defaults = {
"capital_momentum": 0.30,
"supply_demand": 0.30,
"price_action": 0.25,
"trend": 0.15,
"catalyst": 0.30,
"theme_money": 0.25,
"stock_money": 0.20,
"emotion_role": 0.15,
"timing": 0.10,
}
merged = {**defaults, **(weights or {})}
keys = ["capital_momentum", "supply_demand", "price_action", "trend"]
raw = weights or {}
if any(key in raw for key in defaults):
merged = {**defaults, **raw}
else:
merged = {
"catalyst": defaults["catalyst"],
"theme_money": max(float(raw.get("capital_momentum", 0) or 0), 0),
"stock_money": max(float(raw.get("supply_demand", 0) or 0), 0),
"emotion_role": max(float(raw.get("trend", 0) or 0), 0),
"timing": max(float(raw.get("price_action", 0) or 0), 0),
}
keys = list(defaults.keys())
total = sum(max(float(merged.get(k, 0) or 0), 0) for k in keys)
if total <= 0:
return defaults
return {k: max(float(merged.get(k, 0) or 0), 0) / total for k in keys}
def _score_theme_money(
sector_name: str,
hot_sectors: list[SectorInfo],
hot_theme_match: SectorInfo | None,
) -> float:
theme = hot_theme_match
if not theme:
theme = next((s for s in hot_sectors if s.sector_name == sector_name), None)
if not theme:
return 20.0
pct = theme.realtime_pct_change if theme.realtime_pct_change is not None else theme.pct_change
amount = theme.realtime_amount if theme.realtime_amount is not None else theme.capital_inflow
main_force_ratio = theme.main_force_ratio or 0
up = theme.realtime_up_count
down = theme.realtime_down_count
score = min(max(theme.heat_score, 0), 100) * 0.42
if pct >= 4:
score += 18
elif pct >= 2:
score += 14
elif pct > 0:
score += 8
elif pct < -1:
score -= 8
if amount > 500000:
score += 14
elif amount > 200000:
score += 10
elif amount > 0:
score += 6
if main_force_ratio >= 20:
score += 10
elif main_force_ratio >= 10:
score += 6
elif main_force_ratio < 0:
score -= 6
if up is not None and down is not None:
breadth = up - down
if breadth >= 20:
score += 10
elif breadth >= 8:
score += 6
elif breadth < -8:
score -= 8
return round(max(0, min(score, 100)), 1)
def _score_stock_money(stock: dict, capital_score: float) -> float:
main_net = float(stock.get("main_net_inflow", 0) or 0)
inflow_ratio = float(stock.get("inflow_ratio", 0) or 0)
turnover_rate = float(stock.get("turnover_rate", 0) or 0)
volume_ratio = stock.get("volume_ratio")
volume_ratio = float(volume_ratio) if volume_ratio not in (None, "") else 0.0
score = capital_score * 0.55
if main_net > 15000:
score += 18
elif main_net > 8000:
score += 14
elif main_net > 3000:
score += 10
elif main_net < -5000:
score -= 12
if inflow_ratio > 12:
score += 12
elif inflow_ratio > 6:
score += 8
elif inflow_ratio < -6:
score -= 8
if volume_ratio >= 2:
score += 10
elif volume_ratio >= 1.2:
score += 6
if 3 <= turnover_rate <= 15:
score += 8
elif turnover_rate > 0:
score += 3
return round(max(0, min(score, 100)), 1)
def _score_emotion_role(
stock: dict,
sector_limit_up: int,
sector_stage: str,
hot_theme_match: SectorInfo | None,
hot_sectors: list[SectorInfo],
) -> float:
tags = set(stock.get("recall_tags", []) or [])
recall_score = float(stock.get("recall_score", 0) or 0)
score = min(max(recall_score, 0), 100) * 0.45
if hot_theme_match:
try:
rank = hot_sectors.index(hot_theme_match) + 1
except ValueError:
rank = 99
if rank == 1:
score += 16
elif rank <= 3:
score += 12
elif rank <= 5:
score += 7
else:
score -= 10
if "theme_leader" in tags:
score += 18
elif "top_theme_member" in tags:
score += 10
elif "hot_theme_core" in tags:
score += 6
if sector_limit_up >= 5:
score += 14
elif sector_limit_up >= 3:
score += 10
elif sector_limit_up >= 1:
score += 5
if sector_stage == "early":
score += 8
elif sector_stage == "mid":
score += 5
elif sector_stage == "late":
score -= 8
elif sector_stage == "end":
score -= 20
return round(max(0, min(score, 100)), 1)
def _score_timing(
entry_signal_score: float,
price_action_score: float,
trend_score: float,
position_score: float,
) -> float:
score = (
min(max(entry_signal_score, 0), 100) * 0.40 +
min(max(price_action_score, 0), 100) * 0.28 +
min(max(trend_score, 0), 100) * 0.17 +
min(max(position_score, 0), 100) * 0.15
)
return round(max(0, min(score, 100)), 1)
def _score_flow_momentum(stock: dict, sector_name: str, hot_sectors: list[SectorInfo]) -> float:
"""资金顺势评分:个股资金在场 + 主线板块顺风 + 活跃度确认。"""
main_net = float(stock.get("main_net_inflow", 0) or 0)
@ -1093,6 +1397,7 @@ def _score_flow_momentum(stock: dict, sector_name: str, hot_sectors: list[Sector
recall_score = float(stock.get("recall_score", 0) or 0)
sector_heat = _get_sector_heat(sector_name, hot_sectors)
sector_limit_up = _get_sector_limit_up(sector_name, hot_sectors)
catalyst_score = _get_sector_catalyst_score(sector_name, hot_sectors)
score = 0.0
@ -1138,6 +1443,13 @@ def _score_flow_momentum(stock: dict, sector_name: str, hot_sectors: list[Sector
elif sector_limit_up >= 1:
score += 3
if catalyst_score >= 80:
score += 8
elif catalyst_score >= 60:
score += 6
elif catalyst_score >= 40:
score += 3
# 活跃度和召回强度占 35 分。
if volume_ratio >= 2.5:
score += 12
@ -1424,6 +1736,20 @@ def _get_sector_limit_up(sector_name: str, hot_sectors: list[SectorInfo]) -> int
return 0
def _get_sector_catalyst_score(sector_name: str, hot_sectors: list[SectorInfo]) -> float:
for s in hot_sectors:
if s.sector_name == sector_name:
return s.catalyst_score
return 0.0
def _get_sector_catalyst_reasons(sector_name: str, hot_sectors: list[SectorInfo]) -> list[str]:
for s in hot_sectors:
if s.sector_name == sector_name:
return s.catalyst_reasons
return []
def _get_sector_member_count(sector_name: str, hot_sectors: list[SectorInfo]) -> int:
"""获取板块成分股数量"""
for s in hot_sectors:
@ -1699,6 +2025,228 @@ def _build_risk_tags(
return tags
def _build_penalty_notes(
penalties: list[float],
trend_penalty: float,
theme_penalty: float,
market_temp_score: float,
sector_stage: str,
hot_theme_match: SectorInfo | None,
) -> list[dict]:
notes: list[dict] = []
if trend_penalty < 1:
notes.append({"label": "趋势压力", "value": f"-{round((1 - trend_penalty) * 100)}%", "reason": "短中期均线偏弱"})
if sector_stage == "end":
notes.append({"label": "板块尾声", "value": "最高-30%", "reason": "主题阶段进入尾声"})
elif sector_stage == "late":
notes.append({"label": "板块后段", "value": "最高-12%", "reason": "主题阶段偏后"})
if market_temp_score < 30:
notes.append({"label": "市场温度偏冷", "value": "最高-25%", "reason": f"温度{market_temp_score:.0f}"})
elif market_temp_score < 50:
notes.append({"label": "市场温度一般", "value": "最高-12%", "reason": f"温度{market_temp_score:.0f}"})
if theme_penalty < 1:
label = "未匹配主线" if not hot_theme_match else "非前排主线"
notes.append({"label": label, "value": f"-{round((1 - theme_penalty) * 100)}%", "reason": "主题地位不足"})
if not notes and penalties:
notes.append({"label": "风险折扣", "value": f"-{round((1 - min(penalties)) * 100)}%", "reason": "存在风险项"})
return notes[:4]
def _build_decision_trace(
stock: dict,
score: float,
score_weights: dict[str, float],
scoring_axes: dict[str, float],
flow_momentum_score: float,
supply_demand_score: float,
price_action_score: float,
trend_score: float,
capital_score: float,
position_score: float,
valuation_score: float,
entry_signal_type: str,
entry_signal_score: float,
signal_matches_profile: bool,
sector_stage: str,
sector_limit_up: int,
catalyst_score: float,
catalyst_reasons: list[str],
market_temp: MarketTemperature,
trade_plan: dict,
boosts: list[dict],
penalties: list[dict],
risk_tags: list[str],
hot_theme_match: SectorInfo | None,
) -> dict:
tags = stock.get("recall_tags", []) or []
headline = _build_decision_headline(
stock=stock,
action_plan=trade_plan.get("action_plan", "观察"),
entry_signal_type=entry_signal_type,
hot_theme_match=hot_theme_match,
score=score,
)
score_breakdown = [
{
"key": "catalyst",
"label": "新闻催化",
"score": round(scoring_axes.get("catalyst", 0), 1),
"weight": round(score_weights.get("catalyst", 0), 2),
},
{
"key": "theme_money",
"label": "主题资金",
"score": round(scoring_axes.get("theme_money", 0), 1),
"weight": round(score_weights.get("theme_money", 0), 2),
},
{
"key": "stock_money",
"label": "个股资金",
"score": round(scoring_axes.get("stock_money", 0), 1),
"weight": round(score_weights.get("stock_money", 0), 2),
},
{
"key": "emotion_role",
"label": "情绪角色",
"score": round(scoring_axes.get("emotion_role", 0), 1),
"weight": round(score_weights.get("emotion_role", 0), 2),
},
{
"key": "timing",
"label": "入场时机",
"score": round(scoring_axes.get("timing", 0), 1),
"weight": round(score_weights.get("timing", 0), 2),
},
]
evidence = _build_trace_evidence(
tags=tags,
main_net=float(stock.get("main_net_inflow", 0) or 0),
inflow_ratio=float(stock.get("inflow_ratio", 0) or 0),
sector_limit_up=sector_limit_up,
entry_signal_type=entry_signal_type,
entry_signal_score=entry_signal_score,
signal_matches_profile=signal_matches_profile,
hot_theme_match=hot_theme_match,
catalyst_score=catalyst_score,
catalyst_reasons=catalyst_reasons,
)
return {
"version": 1,
"headline": headline,
"action_plan": trade_plan.get("action_plan", "观察"),
"final_score": round(score, 1),
"route_tags": tags,
"evidence": evidence,
"score_breakdown": score_breakdown,
"aux_scores": {
"flow_momentum": round(flow_momentum_score, 1),
"supply_demand": round(supply_demand_score, 1),
"price_action": round(price_action_score, 1),
"trend": round(trend_score, 1),
"capital": round(capital_score, 1),
"position": round(position_score, 1),
"valuation": round(valuation_score, 1),
},
"context": {
"market_temperature": round(market_temp.temperature, 1),
"sector_stage": sector_stage,
"sector_limit_up": sector_limit_up,
"theme_matched": bool(hot_theme_match),
"theme_name": hot_theme_match.sector_name if hot_theme_match else "",
},
"catalyst": {
"score": round(catalyst_score, 1),
"reasons": catalyst_reasons[:3],
},
"boosts": boosts[:4],
"penalties": penalties[:4],
"risk_tags": risk_tags,
"llm_adjustment": None,
}
def _build_decision_headline(
stock: dict,
action_plan: str,
entry_signal_type: str,
hot_theme_match: SectorInfo | None,
score: float,
) -> str:
role = stock.get("stock_role_hint") or "候选标的"
theme = hot_theme_match.sector_name if hot_theme_match else stock.get("sector", "")
signal_label = {
"breakout": "突破",
"breakout_confirm": "突破确认",
"pullback": "回踩",
"launch": "启动",
"reversal": "反转",
"flow_momentum": "资金顺势",
"none": "观察",
}.get(entry_signal_type, entry_signal_type or "观察")
if action_plan == "可操作":
prefix = "可操作"
elif action_plan == "重点关注":
prefix = "重点关注"
else:
prefix = "观察"
theme_part = f"{theme}" if theme else ""
return f"{prefix}: {theme_part}{role}{signal_label}线索,综合分{score:.0f}"
def _build_trace_evidence(
tags: list[str],
main_net: float,
inflow_ratio: float,
sector_limit_up: int,
entry_signal_type: str,
entry_signal_score: float,
signal_matches_profile: bool,
hot_theme_match: SectorInfo | None,
catalyst_score: float = 0,
catalyst_reasons: list[str] | None = None,
) -> list[str]:
evidence: list[str] = []
if hot_theme_match:
evidence.append(f"匹配主线主题 {hot_theme_match.sector_name}")
if catalyst_score > 0:
reason = (catalyst_reasons or [""])[0]
suffix = f": {reason}" if reason else ""
evidence.append(f"新闻/政策催化分 {catalyst_score:.0f}{suffix}")
if tags:
evidence.append("召回来源: " + " / ".join(tags[:3]))
if main_net > 0:
evidence.append(f"主力净流入 {main_net:.0f} 万,占比 {inflow_ratio:.1f}%")
if sector_limit_up > 0:
evidence.append(f"板块涨停扩散 {sector_limit_up}")
if entry_signal_type and entry_signal_type != "none":
evidence.append(f"入场信号 {entry_signal_type},信号分 {entry_signal_score:.0f}")
if signal_matches_profile:
evidence.append("符合今日策略偏好的入场类型")
return evidence[:5]
def _apply_llm_trace(
rec: Recommendation,
verdict: str,
action_plan: str,
conviction: float,
reason: str,
) -> None:
trace = dict(rec.decision_trace or {})
trace["llm_adjustment"] = {
"verdict": verdict,
"action_plan": action_plan,
"conviction": round(conviction, 1),
"reason": str(reason or "")[:180],
}
trace["action_plan"] = action_plan
if verdict == "execute":
trace["headline"] = f"AI确认可执行: {trace.get('headline', rec.name)}"
elif verdict == "skip":
trace["headline"] = f"AI降级观察: {trace.get('headline', rec.name)}"
rec.decision_trace = trace
def _build_focus_points(
stock: dict,
entry_signal: dict,

View File

@ -6,8 +6,6 @@
import logging
from app.analysis.sector_realtime import enrich_sectors_with_realtime
from app.analysis.sector_realtime import get_today_realtime_sector_board
from app.config import settings, should_prefer_realtime_today, today_trade_date
from app.data.models import (
MarketTemperature,
@ -62,12 +60,6 @@ async def build_strategy_board(include_llm: bool = False) -> dict:
market_temp = latest.get("market_temp")
recommendations = latest.get("recommendations", [])
sectors = await get_latest_sectors()
snapshot_trade_date = sectors[0].trade_date if sectors else ""
if should_prefer_realtime_today(snapshot_trade_date) or snapshot_trade_date != today_trade_date():
realtime_sectors = await get_today_realtime_sector_board(limit=20)
sectors = realtime_sectors or await enrich_sectors_with_realtime(sectors)
else:
sectors = await enrich_sectors_with_realtime(sectors)
performance = await get_performance_stats()
from app.llm.strategy_iteration import build_strategy_iteration_report
iteration_report = await build_strategy_iteration_report(limit=50, include_llm=include_llm)

View File

@ -50,7 +50,7 @@ def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile:
name="主线突破",
description="市场偏强,优先寻找主线板块内的突破和突破确认。",
entry_signal_priority=["breakout", "breakout_confirm", "launch", "pullback", "reversal"],
score_weights={"capital_momentum": 0.30, "supply_demand": 0.30, "price_action": 0.25, "trend": 0.15},
score_weights={"catalyst": 0.30, "theme_money": 0.25, "stock_money": 0.20, "emotion_role": 0.15, "timing": 0.10},
min_score=62,
buy_threshold=66,
max_position_pct=30,
@ -67,7 +67,7 @@ def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile:
name="回踩轮动",
description="市场震荡分化,优先做回踩支撑和板块轮动中的低吸确认。",
entry_signal_priority=["pullback", "breakout_confirm", "launch", "breakout", "reversal"],
score_weights={"capital_momentum": 0.28, "supply_demand": 0.30, "price_action": 0.22, "trend": 0.20},
score_weights={"catalyst": 0.25, "theme_money": 0.28, "stock_money": 0.20, "emotion_role": 0.12, "timing": 0.15},
min_score=60,
buy_threshold=63,
max_position_pct=20,
@ -84,7 +84,7 @@ def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile:
name="启动试错",
description="市场偏弱,适合少量观察启动型和反转型机会,不做强追涨。",
entry_signal_priority=["launch", "reversal", "pullback", "breakout_confirm", "breakout"],
score_weights={"capital_momentum": 0.32, "supply_demand": 0.28, "price_action": 0.25, "trend": 0.15},
score_weights={"catalyst": 0.28, "theme_money": 0.22, "stock_money": 0.20, "emotion_role": 0.12, "timing": 0.18},
min_score=58,
buy_threshold=61,
max_position_pct=10,
@ -101,7 +101,7 @@ def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile:
name="防守观察",
description="市场退潮,系统以观察池为主,不主动扩大出手。",
entry_signal_priority=["pullback", "launch", "reversal", "breakout_confirm", "breakout"],
score_weights={"capital_momentum": 0.35, "supply_demand": 0.25, "price_action": 0.25, "trend": 0.15},
score_weights={"catalyst": 0.22, "theme_money": 0.25, "stock_money": 0.18, "emotion_role": 0.15, "timing": 0.20},
min_score=56,
buy_threshold=64,
max_position_pct=5,

View File

@ -11,7 +11,7 @@ from app.config import settings
from app.db.error_logger import log_error
from app.db.database import init_db
from app.engine.scheduler import start_scheduler, stop_scheduler
from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, debug
from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, debug, catalysts
def configure_logging() -> None:
logging.basicConfig(
@ -144,6 +144,7 @@ app.include_router(watchlists.router)
app.include_router(chat.router)
app.include_router(auth.router)
app.include_router(debug.router)
app.include_router(catalysts.router)
# WebSocket
app.websocket("/ws")(websocket.ws_endpoint)

View File

@ -27,8 +27,8 @@ const CHAT_SCENES = [
description: "诊断 / 触发 / 失效",
},
{
title: "系统",
description: "推荐池 / 自选股 / 校准",
title: "自选",
description: "推荐池 / 自选股 / 复盘",
},
];
@ -108,9 +108,9 @@ export default function ChatPage() {
<aside className="hidden xl:flex xl:flex-col xl:gap-4">
<div className="glass-card-static p-5 animate-fade-in-up">
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-cyan-400">
System Agent
Research Desk
</div>
<h1 className="mt-2 text-xl font-bold tracking-tight"></h1>
<h1 className="mt-2 text-xl font-bold tracking-tight"></h1>
</div>
<div className="glass-card-static p-5 animate-fade-in-up">
@ -135,9 +135,9 @@ export default function ChatPage() {
</svg>
</div>
<div>
<h2 className="text-sm font-semibold">A </h2>
<h2 className="text-sm font-semibold">A股研究助手</h2>
<p className="text-xs text-text-muted">
/ / / /
/ / /
</p>
</div>
</div>
@ -161,7 +161,7 @@ export default function ChatPage() {
<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>
</div>
<h3 className="text-lg font-semibold"></h3>
<h3 className="text-lg font-semibold">线</h3>
</div>
<div className="mx-auto mt-8 grid w-full max-w-3xl gap-3 md:grid-cols-2">

View File

@ -54,8 +54,7 @@ export default function DashboardPage() {
if (latest) {
setData(latest);
const realtimeTemp = await fetchAPI<MarketTemperatureData>("/api/market/temperature").catch(() => latest.market_temperature);
setMarketTemperature(realtimeTemp ?? latest.market_temperature ?? null);
setMarketTemperature(latest.market_temperature ?? null);
}
setSectors(sectorData);
if (status) setScanStatus(status);
@ -69,16 +68,16 @@ export default function DashboardPage() {
const loadSecondaryData = useCallback(async () => {
try {
const [board, ops] = await Promise.all([
fetchAPI<StrategyBoard>("/api/market/strategy-board").catch(() => null),
fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null),
]);
const board = await fetchAPI<StrategyBoard>("/api/market/strategy-board").catch(() => null);
const ops = user?.role === "admin"
? await fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null)
: null;
setStrategyBoard(board);
setOpsStatus(ops);
} catch (error) {
console.error("加载次要数据失败:", error);
}
}, []);
}, [user?.role]);
const clearScanTimeout = useCallback(() => {
if (scanTimeoutRef.current) {
@ -99,14 +98,14 @@ export default function DashboardPage() {
useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => {
clearScanTimeout();
if (msg.type === "scan_update") {
const modeLabel = msg.scan_mode === "realtime_today" || msg.scan_mode === "intraday" ? "今日实时" : "历史收盘";
setRefreshResult(`${modeLabel}扫描完成,发现 ${msg.count ?? 0} 只股票`);
const modeLabel = msg.scan_mode === "realtime_today" || msg.scan_mode === "intraday" ? "今日盘中" : "收盘复盘";
setRefreshResult(`${modeLabel}更新完成,保留 ${msg.count ?? 0} 只股票`);
setRefreshing(false);
loadData();
loadSecondaryData();
setTimeout(() => setRefreshResult(null), 5000);
} else if (msg.type === "scan_error") {
setRefreshResult("扫描失败,请重试");
setRefreshResult("更新失败,请重试");
setRefreshing(false);
setTimeout(() => setRefreshResult(null), 5000);
} else {
@ -128,21 +127,21 @@ export default function DashboardPage() {
}>("/api/recommendations/refresh?scan_session=manual");
if (res.status === "already_running") {
setRefreshResult(res.message || "扫描正在执行中,请稍候");
setRefreshResult(res.message || "更新正在执行中,请稍候");
} else if (res.status === "scanning") {
setRefreshResult("扫描已启动,完成后自动刷新...");
setRefreshResult("更新已启动,完成后自动刷新...");
}
scanTimeoutRef.current = setTimeout(() => {
setRefreshResult("扫描超时,已自动刷新数据");
setRefreshResult("更新超时,已自动刷新数据");
setRefreshing(false);
loadData();
loadSecondaryData();
setTimeout(() => setRefreshResult(null), 5000);
}, SCAN_TIMEOUT_MS);
} catch (error) {
console.error("触发扫描失败:", error);
setRefreshResult("触发扫描失败,请重试");
console.error("触发更新失败:", error);
setRefreshResult("触发更新失败,请重试");
setRefreshing(false);
setTimeout(() => setRefreshResult(null), 5000);
}
@ -224,7 +223,7 @@ export default function DashboardPage() {
disabled={refreshing}
className="rounded-xl border border-amber-500/15 bg-amber-500/10 px-4 py-2 text-xs font-medium text-amber-400 transition-colors hover:bg-amber-500/15 disabled:opacity-40"
>
{refreshing ? "扫描中..." : scanStatus?.is_trading ? "盘中扫描" : "立即扫描"}
{refreshing ? "更新中..." : scanStatus?.is_trading ? "盘中更新" : "立即更新"}
</button>
) : null}
</div>
@ -236,20 +235,13 @@ export default function DashboardPage() {
</div>
) : null}
<DecisionHero
board={strategyBoard}
summary={marketSummary}
actionableCount={actionable.length}
watchCount={watch.length}
observeCount={observe.length}
/>
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.2fr)_minmax(300px,0.8fr)] gap-4 animate-fade-in-up">
<ActionPanel
actions={todayActions}
<div className="animate-fade-in-up">
<DecisionHero
board={strategyBoard}
summary={marketSummary}
/>
<FocusPanel
actions={todayActions}
marketTemperature={marketTemperature ?? data?.market_temperature ?? null}
sectors={sectors}
focusQueue={focusQueue.slice(0, 3)}
actionableCount={actionable.length}
watchCount={watch.length}
@ -281,108 +273,87 @@ export default function DashboardPage() {
function DecisionHero({
board,
summary,
actions,
marketTemperature,
sectors,
focusQueue,
actionableCount,
watchCount,
observeCount,
}: {
board: StrategyBoard | null;
summary: ReturnType<typeof buildMarketSummary>;
actionableCount: number;
watchCount: number;
observeCount: number;
}) {
const isRealtime = board?.data_mode === "realtime_today";
return (
<div className="glass-card-static animate-fade-in-up overflow-hidden p-4 md:p-5">
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-[0.2em] text-amber-400"></span>
{isRealtime ? (
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] text-emerald-400"></span>
) : null}
</div>
<h2 className="mt-2 text-xl font-bold tracking-tight text-text-primary">{summary.headline}</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary line-clamp-2">
{summary.detail}
</p>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
<DecisionList title="动作" items={summary.canDo} tone="positive" />
<DecisionList title="边界" items={summary.cannotDo} tone="risk" />
</div>
</div>
<div className="grid grid-cols-3 gap-2 self-start">
<HeroMetric label="可操作" value={actionableCount} tone="text-red-400" />
<HeroMetric label="关注" value={watchCount} tone="text-amber-400" />
<HeroMetric label="观察" value={observeCount} tone="text-text-secondary" />
<HeroFact label="打法" value={summary.modeLabel} />
<HeroFact label="仓位" value={summary.positionLabel} />
<HeroFact label="风险" value={board?.risk_level ?? "等待更新"} />
</div>
</div>
</div>
);
}
function ActionPanel({
actions,
summary,
}: {
actions: ReturnType<typeof buildActionGuides>;
summary: ReturnType<typeof buildMarketSummary>;
}) {
return (
<div className="glass-card-static p-4 md:p-5">
<div className="flex flex-wrap items-center gap-2">
<CompactBadge label="策略" value={summary.modeLabel} />
<CompactBadge label="仓位" value={summary.positionLabel} />
<CompactBadge label="风险" value={summary.riskLabel} />
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<ActionBucket title="现在做" items={actions.priority} tone="priority" />
<ActionBucket title="盯住" items={actions.watch} tone="watch" />
<ActionBucket title="不要做" items={actions.avoid} tone="avoid" />
</div>
</div>
);
}
function FocusPanel({
focusQueue,
actionableCount,
watchCount,
observeCount,
}: {
marketTemperature: MarketTemperatureData | null;
sectors: SectorData[];
focusQueue: RecommendationData[];
actionableCount: number;
watchCount: number;
observeCount: number;
}) {
return (
<div className="glass-card-static p-4 md:p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-primary"></h3>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<MiniCount label="可操作" value={actionableCount} tone="text-red-400" />
<MiniCount label="关注" value={watchCount} tone="text-amber-400" />
<MiniCount label="观察" value={observeCount} tone="text-text-secondary" />
</div>
</div>
const leadingSectors = sectors.slice(0, 3);
<div className="mt-4 space-y-3">
{focusQueue.length ? (
focusQueue.map((rec) => <FocusStockCard key={rec.ts_code} rec={rec} />)
) : (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-6 text-center text-sm text-text-muted">
return (
<div className="glass-card-static overflow-hidden">
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<div className="p-5 md:p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400"></span>
<div className="flex flex-wrap gap-2">
<CompactBadge label="打法" value={summary.modeLabel} />
<CompactBadge label="仓位" value={summary.positionLabel} />
<CompactBadge label="风险" value={board?.risk_level ?? "等待更新"} />
</div>
</div>
)}
<div className="mt-5 grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_160px] gap-4">
<div>
<h2 className="text-3xl md:text-4xl font-bold tracking-tight text-text-primary">{summary.headline}</h2>
<p className="mt-3 max-w-3xl text-sm leading-6 text-text-secondary">{summary.detail}</p>
</div>
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4 text-center">
<div className="text-[10px] uppercase tracking-wider text-text-muted"></div>
<div className="mt-1 text-3xl font-bold font-mono tabular-nums text-amber-400">
{Math.round(marketTemperature?.temperature ?? 0)}
</div>
</div>
</div>
<div className="mt-5 grid grid-cols-1 md:grid-cols-3 gap-3">
<ActionBucket title="现在做" items={actions.priority.slice(0, 2)} tone="do" />
<ActionBucket title="等待确认" items={actions.watch.slice(0, 2)} tone="wait" />
<ActionBucket title="不要做" items={actions.avoid.slice(0, 2)} tone="avoid" />
</div>
{leadingSectors.length ? (
<div className="mt-5 flex flex-wrap gap-2">
{leadingSectors.map((sector) => (
<SectorChip key={sector.sector_code} sector={sector} />
))}
</div>
) : null}
</div>
<div className="border-t border-border-subtle bg-surface-1/50 p-5 xl:border-l xl:border-t-0">
<div className="grid grid-cols-3 gap-2">
<HeroMetric label="可操作" value={actionableCount} tone="text-red-400" />
<HeroMetric label="关注" value={watchCount} tone="text-amber-400" />
<HeroMetric label="观察" value={observeCount} tone="text-text-secondary" />
</div>
<div className="mt-5">
<div className="text-[11px] font-semibold text-text-secondary"></div>
<div className="mt-3 space-y-2.5">
{focusQueue.length ? (
focusQueue.map((rec) => <FocusStockCard key={rec.ts_code} rec={rec} />)
) : (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-6 text-center text-sm text-text-muted">
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
@ -406,7 +377,7 @@ function MarketSnapshot({
<div className="glass-card-static p-4 md:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-primary"></h3>
<h3 className="text-sm font-semibold text-text-primary"></h3>
</div>
<span className="rounded-xl bg-surface-1/70 px-3 py-1.5 text-xs font-mono tabular-nums text-text-secondary">
{Math.round(marketTemperature?.temperature ?? 0)}
@ -527,7 +498,7 @@ function AdminPanel({
disabled={refreshing || !!opsRunning}
className="rounded-xl border border-amber-500/15 bg-amber-500/10 px-3 py-2 text-xs text-amber-400 disabled:opacity-40"
>
{refreshing ? "扫描中..." : "立即扫描"}
{refreshing ? "更新中..." : "立即更新"}
</button>
<button
onClick={() => onAction("update_tracking")}
@ -541,32 +512,6 @@ function AdminPanel({
);
}
function DecisionList({
title,
items,
tone,
}: {
title: string;
items: string[];
tone: "positive" | "risk";
}) {
const dotClass = tone === "positive" ? "bg-emerald-400" : "bg-amber-400";
return (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
<div className="text-[11px] font-semibold text-text-secondary">{title}</div>
<div className="mt-2 space-y-2">
{items.map((item, index) => (
<div key={`${title}-${index}`} className="flex items-start gap-2 text-sm text-text-secondary">
<span className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${dotClass}`} />
<span>{item}</span>
</div>
))}
</div>
</div>
);
}
function HeroMetric({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3 text-center">
@ -576,11 +521,24 @@ function HeroMetric({ label, value, tone }: { label: string; value: number; tone
);
}
function HeroFact({ label, value }: { label: string; value: string }) {
function ActionBucket({ title, items, tone }: { title: string; items: string[]; tone: "do" | "wait" | "avoid" }) {
const toneClass =
tone === "do"
? "text-emerald-400 bg-emerald-500/[0.06] border-emerald-500/10"
: tone === "wait"
? "text-amber-400 bg-amber-500/[0.06] border-amber-500/10"
: "text-text-muted bg-surface-2/70 border-border-subtle";
return (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3">
<div className="text-[10px] uppercase tracking-wider text-text-muted">{label}</div>
<div className="mt-1 text-xs font-semibold leading-5 text-text-secondary line-clamp-2">{value}</div>
<div className={`rounded-2xl border p-3 ${toneClass}`}>
<div className="text-[11px] font-semibold">{title}</div>
<div className="mt-2 space-y-2">
{items.map((item, index) => (
<div key={`${title}-${index}`} className="text-sm leading-6 text-text-secondary">
{item}
</div>
))}
</div>
</div>
);
}
@ -594,81 +552,69 @@ function CompactBadge({ label, value }: { label: string; value: string }) {
);
}
function ActionBucket({
title,
items,
tone,
}: {
title: string;
items: string[];
tone: "priority" | "watch" | "avoid";
}) {
const toneClass =
tone === "priority"
? "bg-red-500/8 text-red-400"
: tone === "watch"
? "bg-amber-500/8 text-amber-400"
: "bg-surface-2 text-text-muted";
function SectorChip({ sector }: { sector: SectorData }) {
const pct = sector.realtime_pct_change ?? sector.pct_change;
return (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
<div className={`inline-flex rounded-lg px-2 py-1 text-[10px] font-semibold uppercase tracking-wider ${toneClass}`}>
{title}
</div>
<div className="mt-3 space-y-2">
{items.map((item, index) => (
<div key={`${title}-${index}`} className="rounded-xl bg-surface-2/70 px-3 py-2 text-sm leading-6 text-text-secondary">
{item}
</div>
))}
</div>
</div>
<span className="inline-flex items-center gap-2 rounded-xl border border-border-subtle bg-surface-2/70 px-3 py-1.5 text-xs">
<span className="max-w-[8rem] truncate text-text-secondary">{sector.sector_name}</span>
<span className={`font-mono tabular-nums ${pct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
{pct >= 0 ? "+" : ""}{pct.toFixed(2)}%
</span>
</span>
);
}
function FocusStockCard({ rec }: { rec: RecommendationData }) {
const badgeClass =
const stripeClass =
rec.action_plan === "可操作"
? "border-red-500/20 bg-red-500/10 text-red-400"
? "bg-red-400"
: rec.action_plan === "重点关注"
? "border-amber-500/20 bg-amber-500/10 text-amber-400"
: "border-border-subtle bg-surface-2 text-text-muted";
? "bg-amber-400"
: "bg-text-muted";
const labelClass =
rec.action_plan === "可操作"
? "text-red-400"
: rec.action_plan === "重点关注"
? "text-amber-400"
: "text-text-muted";
return (
<a
href={`/stock/${rec.ts_code}`}
className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4 transition-colors hover:border-amber-500/20"
className="group grid grid-cols-[3px_minmax(0,1fr)] gap-3 rounded-lg px-1 py-2 transition-colors hover:bg-surface-2/60"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-primary">{rec.name}</span>
<span className={`rounded-lg border px-2 py-0.5 text-[10px] ${badgeClass}`}>{rec.action_plan ?? "观察"}</span>
<span className={`mt-1 h-[calc(100%-0.5rem)] rounded-full ${stripeClass}`} />
<div className="min-w-0">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex items-center gap-2">
<span className="truncate text-sm font-semibold text-text-primary">{rec.name}</span>
<span className={`shrink-0 text-[10px] font-medium ${labelClass}`}>{rec.action_plan ?? "观察"}</span>
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5 text-[11px] text-text-muted">
<span className="font-mono tabular-nums">{rec.ts_code}</span>
{rec.sector ? (
<span className="inline-flex max-w-[10rem] items-center rounded-md border border-border-subtle bg-surface-2/70 px-1.5 py-0.5 font-medium text-text-secondary">
<span className="truncate">{rec.sector}</span>
</span>
) : null}
{rec.suggested_position_pct != null ? (
<span className="shrink-0 font-mono text-[11px] tabular-nums text-text-muted">{rec.suggested_position_pct}%</span>
) : null}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] text-text-muted">
<span className="font-mono tabular-nums">{rec.ts_code}</span>
{rec.sector ? (
<>
<span className="text-text-muted/50">/</span>
<span className="truncate text-text-secondary">{rec.sector}</span>
</>
) : null}
</div>
<div className="mt-1.5 text-xs leading-5 text-text-secondary line-clamp-2">
{rec.decision_trace?.headline ?? rec.trigger_condition ?? rec.entry_timing ?? rec.prefilter_reason ?? rec.reasons?.[0] ?? "等待新的触发条件。"}
</div>
{(rec.invalidation_condition || rec.risk_note) ? (
<div className="mt-1 text-[11px] leading-5 text-text-muted line-clamp-1">
: {rec.invalidation_condition ?? rec.risk_note}
</div>
</div>
<div className="text-right">
<div className="text-xs font-mono tabular-nums text-text-secondary">{Math.round(rec.score)}</div>
<div className="mt-0.5 text-[10px] text-text-muted"></div>
</div>
) : null}
</div>
<div className="mt-3 text-sm leading-6 text-text-secondary">
{rec.trigger_condition ?? rec.entry_timing ?? rec.prefilter_reason ?? rec.reasons?.[0] ?? "等待新的触发条件。"}
</div>
{(rec.invalidation_condition || rec.risk_note) ? (
<div className="mt-3 rounded-xl bg-surface-2/70 px-3 py-2 text-[11px] leading-5 text-text-muted">
: {rec.invalidation_condition ?? rec.risk_note}
</div>
) : null}
</a>
);
}
@ -682,15 +628,6 @@ function EvidenceStat({ label, value, tone }: { label: string; value: number; to
);
}
function MiniCount({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div className="rounded-xl bg-surface-2/70 px-2 py-2">
<div className="text-[10px] text-text-muted">{label}</div>
<div className={`mt-1 text-sm font-semibold font-mono tabular-nums ${tone}`}>{value}</div>
</div>
);
}
function FreshnessPill({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl bg-surface-1/70 px-2.5 py-2">
@ -722,8 +659,8 @@ function buildMarketSummary(
strategyProfile?.decision_note ??
board?.summary ??
(scanStatus?.is_trading
? "盘中实时:看节奏、仓位、主线强弱。"
: "收盘快照:复盘主线,准备下一交易日。");
? "当前结论:看节奏、仓位、主线强弱。"
: "最近结论:复盘主线,准备下一交易日。");
const canDo = [
!allowTrading
@ -748,7 +685,6 @@ function buildMarketSummary(
cannotDo,
modeLabel: strategyProfile?.market_stance || board?.recommended_mode || "等待更新",
positionLabel: board?.position_suggestion || (strategyProfile?.max_position_pct ? `${strategyProfile.max_position_pct}% 以内` : "等待更新"),
riskLabel: board?.risk_level || "等待更新",
};
}
@ -795,7 +731,7 @@ function buildActionGuides(
watch[1]
? `${watch[1].name}:观察队列。`
: "新热点先看板块扩散。",
observe.length > 0 ? `后台观察 ${observe.length} 只。` : "暂无弱候选。",
observe.length > 0 ? `观察 ${observe.length} 只。` : "暂无弱候选。",
];
const avoid = [

View File

@ -191,7 +191,7 @@ export default function DiagnosePage() {
</svg>
</div>
<div>
<h1 className="text-lg font-bold tracking-tight">AI </h1>
<h1 className="text-lg font-bold tracking-tight"></h1>
</div>
</div>
</div>
@ -278,9 +278,9 @@ export default function DiagnosePage() {
{thesis ? (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold"></div>
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold"></div>
<div className="text-sm text-text-secondary mt-2 leading-relaxed">
{thesis.data_freshness.message}
</div>
<div className="grid grid-cols-1 gap-2 mt-3">
{(thesis.decision_points ?? []).slice(0, 3).map((point) => (
@ -310,7 +310,7 @@ export default function DiagnosePage() {
<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>
<span className="text-xs text-text-muted">...</span>
</div>
<div className="text-sm text-text-secondary leading-relaxed whitespace-pre-line">
{displayContent}
@ -349,8 +349,8 @@ export default function DiagnosePage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<DiagnosisSummaryCard label="执行建议" value={buildDiagnosisAction(thesis, diagnoseMode)} />
<DiagnosisSummaryCard label="下一步" value={buildDiagnosisNextStep(thesis, diagnoseMode)} />
<DiagnosisSummaryCard label="触发关注" value={thesis?.recommendation?.trigger_condition || "等待 AI 在长文中补充"} />
<DiagnosisSummaryCard label="失效边界" value={thesis?.recommendation?.invalidation_condition || "等待 AI 在长文中补充"} />
<DiagnosisSummaryCard label="触发关注" value={thesis?.recommendation?.trigger_condition || "等待正文补充"} />
<DiagnosisSummaryCard label="失效边界" value={thesis?.recommendation?.invalidation_condition || "等待正文补充"} />
</div>
</div>
<div className="glass-card-static p-5">
@ -358,7 +358,7 @@ export default function DiagnosePage() {
<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 ? "缓存" : "分析完成"}
{cachedResult ? "历史结论" : "分析完成"}
</span>
</div>
</div>
@ -387,7 +387,7 @@ export default function DiagnosePage() {
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
</div>
<div className="text-sm text-text-muted mb-2"> AI </div>
<div className="text-sm text-text-muted mb-2"></div>
<div className="text-xs text-text-muted/50 mb-4">
600683
</div>
@ -494,7 +494,7 @@ function buildDiagnosisConclusion(thesis: StockThesisResponse | null, loading: b
return thesis.recommendation.action_plan;
}
if (loading) {
return "AI 正在生成会诊";
return "正在生成会诊";
}
if (thesis?.has_recommendation === false) {
return "暂无推荐归档,以本次诊断为准";
@ -510,7 +510,7 @@ function buildDiagnosisRisk(thesis: StockThesisResponse | null, loading: boolean
return thesis.latest_tracking.review_note;
}
if (loading) {
return "AI 正在补充风险判断";
return "正在补充风险判断";
}
return "当前没有明确风险备注";
}

View File

@ -13,10 +13,10 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
<div className="px-6 pt-7 pb-5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-sm font-bold text-white shadow-glow-sm">
D
A
</div>
<div>
<h1 className="text-sm font-semibold tracking-tight">Dragon AI Agent</h1>
<h1 className="text-sm font-semibold tracking-tight">AlphaX Agent</h1>
<p className="text-xs text-text-muted mt-0.5 font-light tracking-wide">A </p>
</div>
</div>

View File

@ -9,6 +9,7 @@ import type {
RecommendationData,
} from "@/lib/api";
import StockCard from "@/components/stock-card";
import { useAuth } from "@/hooks/use-auth";
function formatDate(dateStr: string): string {
const d = new Date(dateStr);
@ -25,7 +26,7 @@ function formatDate(dateStr: string): string {
type RecommendationWithDate = RecommendationData & { groupDate: string };
type FocusTab = "actionable" | "watch" | "tracking" | "closed";
type FocusTab = "actionable" | "watch" | "observe" | "tracking" | "closed";
const SIGNAL_FILTERS = [
{ key: "all", label: "全部" },
@ -36,6 +37,7 @@ const SIGNAL_FILTERS = [
];
export default function RecommendationsPage() {
const { user } = useAuth();
const [dayGroups, setDayGroups] = useState<DayGroup[]>([]);
const [latest, setLatest] = useState<LatestResult | null>(null);
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
@ -48,7 +50,7 @@ export default function RecommendationsPage() {
const [history, latestResult, ops] = await Promise.all([
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
fetchAPI<LatestResult>("/api/recommendations/latest").catch(() => null),
fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null),
user?.role === "admin" ? fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null) : Promise.resolve(null),
]);
setDayGroups(history);
@ -62,7 +64,7 @@ export default function RecommendationsPage() {
} catch (error) {
console.error("加载推荐失败:", error);
}
}, []);
}, [user?.role]);
useEffect(() => {
loadData();
@ -103,6 +105,7 @@ export default function RecommendationsPage() {
const focusTabs: Array<{ key: FocusTab; label: string; count: number; description: string }> = [
{ key: "actionable", label: "可操作", count: actionable.length, description: "执行名单" },
{ key: "watch", label: "重点关注", count: watch.length, description: "等待确认" },
{ key: "observe", label: "观察池", count: observe.length, description: "不急处理" },
{ key: "tracking", label: "跟踪中", count: tracking.length, description: "兑现进度" },
{ key: "closed", label: "已结束", count: closed.length, description: "复盘样本" },
];
@ -110,9 +113,10 @@ export default function RecommendationsPage() {
const focusItems = useMemo(() => {
if (focusTab === "actionable") return actionable;
if (focusTab === "watch") return watch;
if (focusTab === "observe") return observe.slice(0, 12);
if (focusTab === "tracking") return tracking.slice(0, 8);
return closed.slice(0, 8);
}, [actionable, closed, focusTab, tracking, watch]);
}, [actionable, closed, focusTab, observe, tracking, watch]);
const themeFocus = useMemo(() => {
const source = latestRecommendations.filter((rec) => ["可操作", "重点关注"].includes(rec.action_plan ?? ""));
@ -144,18 +148,18 @@ export default function RecommendationsPage() {
<h1 className="text-base sm:text-lg font-bold tracking-tight"></h1>
</div>
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4">
<div>
<div className="glass-card-static overflow-hidden animate-fade-in-up">
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px]">
<div className="p-4 md:p-5">
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold"></div>
<h2 className="mt-2 text-xl font-bold tracking-tight text-text-primary">{focusSummary.headline}</h2>
<p className="mt-2 text-sm leading-6 text-text-secondary line-clamp-2">{focusSummary.detail}</p>
<h2 className="mt-2 text-2xl md:text-3xl font-bold tracking-tight text-text-primary">{focusSummary.headline}</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary">{focusSummary.detail}</p>
{latest?.strategy_profile ? (
<div className="mt-3 flex flex-wrap gap-2">
<SummaryChip label="策略" value={latest.strategy_profile.name} />
<SummaryChip label="打法" value={latest.strategy_profile.name} />
<SummaryChip label="立场" value={latest.strategy_profile.market_stance || (latest.strategy_profile.allow_trading ? "谨慎进攻" : "防守观察")} />
<SummaryChip label="仓位上限" value={`${latest.strategy_profile.max_position_pct ?? 0}%`} />
<SummaryChip label="仓位边界" value={`${latest.strategy_profile.max_position_pct ?? 0}%`} />
</div>
) : null}
@ -179,18 +183,22 @@ export default function RecommendationsPage() {
</div>
</div>
<div className="grid grid-cols-2 gap-2 self-start">
<SummaryMetric label="今日保留" value={latestRecommendations.length} tone="text-text-primary" />
<SummaryMetric label="可操作" value={actionable.length} tone="text-red-400" />
<SummaryMetric label="重点关注" value={watch.length} tone="text-amber-400" />
<SummaryMetric label="跟踪中" value={tracking.length} tone="text-cyan-400" />
<SummaryFact label="历史累计" value={`${totalCount}`} />
<SummaryFact label="已结束样本" value={`${closed.length}`} />
<div className="border-t border-border-subtle bg-surface-1/50 p-4 md:p-5 xl:border-l xl:border-t-0">
<div className="grid grid-cols-2 gap-2">
<SummaryMetric label="今日保留" value={latestRecommendations.length} tone="text-text-primary" />
<SummaryMetric label="可操作" value={actionable.length} tone="text-red-400" />
<SummaryMetric label="重点关注" value={watch.length} tone="text-amber-400" />
<SummaryMetric label="观察池" value={observe.length} tone="text-text-secondary" />
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<SummaryFact label="历史记录" value={`${totalCount}`} />
<SummaryFact label="已复盘" value={`${closed.length}`} />
</div>
</div>
</div>
</div>
{opsStatus ? (
{user?.role === "admin" && opsStatus ? (
<div className="glass-card-static p-3.5 animate-fade-in-up">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
<div className="text-xs text-text-secondary">
@ -206,40 +214,34 @@ export default function RecommendationsPage() {
</div>
) : null}
<div className="grid grid-cols-1 xl:grid-cols-[260px_minmax(0,1fr)] gap-4 animate-fade-in-up">
<div className="glass-card-static p-3">
<div className="text-[10px] uppercase tracking-[0.22em] text-text-muted font-semibold mb-3"></div>
<div className="space-y-2">
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-base font-bold tracking-tight text-text-primary">
{focusTabs.find((tab) => tab.key === focusTab)?.label ?? "焦点标的"}
</h2>
<div className="mt-1 text-xs text-text-muted">
{focusTabs.find((tab) => tab.key === focusTab)?.description ?? "按当前结论处理"}
</div>
</div>
<div className="flex gap-2 overflow-x-auto pb-1">
{focusTabs.map((tab) => (
<button
key={tab.key}
onClick={() => setFocusTab(tab.key)}
className={`w-full rounded-2xl border px-3 py-3 text-left transition-all ${
className={`shrink-0 rounded-xl border px-3 py-2 text-left transition-all ${
focusTab === tab.key
? "border-amber-500/20 bg-amber-500/[0.06]"
: "border-border-subtle bg-surface-1 hover:bg-surface-2"
}`}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-text-primary">{tab.label}</div>
<div className="mt-1 text-[11px] leading-5 text-text-muted">{tab.description}</div>
</div>
<span className="rounded-lg bg-surface-2 px-2 py-1 text-xs font-mono tabular-nums text-text-secondary">
{tab.count}
</span>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-text-primary">{tab.label}</span>
<span className="font-mono text-xs tabular-nums text-text-muted">{tab.count}</span>
</div>
</button>
))}
</div>
</div>
<div className="glass-card-static p-4 md:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-base font-bold tracking-tight text-text-primary">
{focusTabs.find((tab) => tab.key === focusTab)?.label ?? "焦点标的"}
</h2>
</div>
</div>
@ -257,33 +259,6 @@ export default function RecommendationsPage() {
</div>
</div>
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-text-primary"></h2>
</div>
<span className="text-xs text-text-muted">{observe.length} </span>
</div>
{observe.length ? (
<div className="mt-4 flex flex-wrap gap-2">
{observe.slice(0, 12).map((rec) => (
<a
key={`observe-${rec.ts_code}`}
href={`/stock/${rec.ts_code}`}
className="rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-2 text-xs text-text-secondary transition-colors hover:border-amber-500/20"
>
<span className="font-semibold text-text-primary">{rec.name}</span>
<span className="mx-1 text-text-muted">·</span>
<span>{rec.sector}</span>
</a>
))}
</div>
) : (
<div className="mt-4 text-sm text-text-muted"></div>
)}
</div>
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
<div>
@ -410,7 +385,7 @@ function buildFocusSummary({
];
const later = [
observe.length > 0 ? `${observe.length}后台观察。` : "不堆弱标的。",
observe.length > 0 ? `${observe.length}观察池标的` : "不堆弱标的。",
closed.length > 0 ? `${closed.length} 只已结束样本。` : "暂无结束样本。",
"不追无触发标的。",
];

View File

@ -2,14 +2,14 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchAPI } from "@/lib/api";
import type { LeadingStock, SectorData, SectorRotationData } from "@/lib/api";
import type { LeadingStock, SectorData } from "@/lib/api";
import { formatNumber } from "@/lib/utils";
import { ErrorBoundary } from "@/components/error-boundary";
import { useWebSocket } from "@/hooks/use-websocket";
function getThemeAliasLine(sector: SectorData) {
const aliases = (sector.theme_aliases ?? []).filter((alias) => alias && alias !== sector.sector_name).slice(0, 4);
if (!aliases.length) return "系统主题";
if (!aliases.length) return "主题归类待补充";
return `包含:${aliases.join(" / ")}`;
}
@ -70,6 +70,14 @@ function getLeaders(sector: SectorData): LeadingStock[] {
: sector.leading_stocks ?? [];
}
function getCatalystLabel(sector: SectorData) {
const score = sector.catalyst_score ?? 0;
if (score >= 70) return { label: `强催化 ${score.toFixed(0)}`, className: "border-red-500/15 bg-red-500/10 text-red-300" };
if (score >= 45) return { label: `有催化 ${score.toFixed(0)}`, className: "border-amber-500/15 bg-amber-500/10 text-amber-300" };
if ((sector.catalyst_count ?? 0) > 0) return { label: `催化 ${score.toFixed(0)}`, className: "border-border-subtle bg-surface-2 text-text-secondary" };
return null;
}
function getHeadline(sectors: SectorData[]) {
const primary = sectors[0];
const secondary = sectors[1];
@ -78,8 +86,8 @@ function getHeadline(sectors: SectorData[]) {
if (!primary) {
return {
title: "暂无主线数据",
detail: "等待实时主题榜或扫描结果更新后再判断。",
canDo: ["等待主题榜更新。"],
detail: "等待新的主线结论后再判断。",
canDo: ["等待主线结论更新。"],
avoid: ["不做情绪追涨。"],
};
}
@ -120,24 +128,6 @@ function getHeadline(sectors: SectorData[]) {
};
}
function getSourceLabel(source?: string) {
if (source === "eastmoney") return "东方财富";
if (source === "sina") return "新浪";
if (source === "snapshot") return "本地快照";
if (source === "mixed") return "多源";
return "未知来源";
}
function getSourceRiskHint(source?: string, dataMode?: string) {
if (source === "snapshot" || dataMode === "daily_snapshot") {
return "快照口径";
}
if (source === "sina") {
return "新浪口径";
}
return "实时口径";
}
function LeadingStockPill({ stock }: { stock: LeadingStock }) {
return (
<a
@ -209,7 +199,7 @@ function LaneCard({
</div>
<div className="mt-3 space-y-2.5">
{sectors.length ? sectors.map((sector) => <LaneRow key={sector.sector_code} sector={sector} />) : (
{sectors.length ? sectors.map((sector, index) => <LaneRow key={sector.sector_code} sector={sector} rank={index + 1} />) : (
<div className="text-xs text-text-muted"></div>
)}
</div>
@ -217,32 +207,43 @@ function LaneCard({
);
}
function LaneRow({ sector }: { sector: SectorData }) {
function LaneRow({ sector, rank }: { sector: SectorData; rank: number }) {
const displayPct = sector.realtime_pct_change ?? sector.pct_change;
const stage = getStageInfo(sector.stage ?? "");
const action = getActionPlan(sector);
const leaders = getLeaders(sector).slice(0, 2);
const catalyst = getCatalystLabel(sector);
return (
<div className="rounded-xl border border-border-subtle bg-surface-2/70 px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-text-primary">{sector.sector_name}</span>
{sector.board_type === "theme" ? (
<span className="rounded-md border border-sky-500/15 bg-sky-500/10 px-1.5 py-0.5 text-[10px] text-sky-300">
<div className="min-w-0 flex gap-3">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-surface-1 font-mono text-xs text-text-muted">
{rank}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-text-primary">{sector.sector_name}</span>
{sector.board_type === "theme" ? (
<span className="rounded-md border border-sky-500/15 bg-sky-500/10 px-1.5 py-0.5 text-[10px] text-sky-300">
</span>
) : null}
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${stage.bg} ${stage.color}`}>
{stage.label}
</span>
) : null}
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${stage.bg} ${stage.color}`}>
{stage.label}
</span>
</div>
<div className="mt-1 text-[11px] text-text-muted">
{getThemeAliasLine(sector)}
</div>
<div className="mt-1 text-[11px] text-text-muted">
{leaders.length ? leaders.map((item) => item.name).join(" / ") : "暂无"}
{catalyst ? (
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${catalyst.className}`}>
{catalyst.label}
</span>
) : null}
</div>
<div className="mt-1 text-[11px] text-text-muted">
{getThemeAliasLine(sector)}
</div>
<div className="mt-1 text-[11px] text-text-muted">
{leaders.length ? leaders.map((item) => item.name).join(" / ") : "暂无"}
</div>
</div>
</div>
<div className="text-right shrink-0">
@ -268,39 +269,35 @@ function SectorCard({ sector, index }: { sector: SectorData; index: number }) {
const displayPct = sector.realtime_pct_change ?? sector.pct_change;
const displayAmount = sector.realtime_amount ?? sector.capital_inflow;
const displayLimitUp = sector.realtime_limit_up_count ?? sector.limit_up_count;
const displayUpCount = sector.realtime_up_count;
const displayDownCount = sector.realtime_down_count;
const stage = getStageInfo(sector.stage ?? "");
const leaders = getLeaders(sector).slice(0, 3);
const action = getActionPlan(sector);
const catalyst = getCatalystLabel(sector);
const catalystReason = sector.catalyst_reasons?.[0];
return (
<div className="glass-card animate-fade-in-up overflow-hidden" style={{ animationDelay: `${index * 40}ms` }}>
<div className="p-4">
<div className="rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-3 animate-fade-in-up" style={{ animationDelay: `${index * 30}ms` }}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-text-primary">{sector.sector_name}</span>
{sector.board_type === "theme" ? (
<span className="rounded-md border border-sky-500/15 bg-sky-500/10 px-1.5 py-0.5 text-[10px] text-sky-300">
</span>
) : null}
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${stage.bg} ${stage.color}`}>
{stage.label}
</span>
{sector.is_realtime ? (
<span className="rounded-md border border-emerald-500/15 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] text-emerald-400/80">
{catalyst ? (
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${catalyst.className}`}>
{catalyst.label}
</span>
) : null}
</div>
<div className="mt-1 text-[11px] text-text-muted">
{action.label} · {action.description}
</div>
<div className="mt-1 text-[11px] text-text-muted">
{getThemeAliasLine(sector)}
</div>
</div>
<div className="text-right shrink-0">
<div className={`text-base font-bold font-mono tabular-nums ${displayPct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
@ -310,32 +307,32 @@ function SectorCard({ sector, index }: { sector: SectorData; index: number }) {
</div>
</div>
<div className="mt-3 grid grid-cols-4 gap-2">
<MetricBox label={sector.is_realtime ? "成交额" : "资金"}>
<div className="mt-3 grid grid-cols-3 gap-2">
<MetricBox label="资金">
<span className={`font-mono tabular-nums ${displayAmount >= 0 ? "text-red-400" : "text-emerald-400"}`}>
{displayAmount >= 0 ? "+" : ""}{formatNumber(displayAmount)}
</span>
</MetricBox>
<MetricBox label={sector.is_realtime ? "上涨/下跌" : "涨停"}>
{sector.is_realtime && displayUpCount != null && displayDownCount != null ? (
<span className="font-mono tabular-nums text-text-secondary">{displayUpCount}/{displayDownCount}</span>
) : (
<span className="font-mono tabular-nums text-text-secondary">{displayLimitUp}</span>
)}
<MetricBox label="涨停">
<span className="font-mono tabular-nums text-text-secondary">{displayLimitUp}</span>
</MetricBox>
<MetricBox label={sector.is_realtime ? "换手" : "连板"}>
<span className="font-mono tabular-nums text-text-secondary">
{sector.is_realtime ? `${(sector.realtime_turnover_rate ?? sector.turnover_avg ?? 0).toFixed(1)}%` : `${sector.days_continuous}`}
</span>
</MetricBox>
<MetricBox label="主力占比">
<MetricBox label="主力">
<span className="font-mono tabular-nums text-text-secondary">{(sector.main_force_ratio ?? 0).toFixed(1)}%</span>
</MetricBox>
</div>
<div className="mt-3 rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-text-muted font-semibold"></div>
<div className="mt-1 text-[12px] leading-6 text-text-secondary">
{catalystReason ? (
<div className="mt-3 rounded-xl border border-amber-500/15 bg-amber-500/[0.06] px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-amber-300/80 font-semibold">/</div>
<div className="mt-1 text-[12px] leading-6 text-text-secondary">
{catalystReason}
</div>
</div>
) : null}
<div className="mt-3 rounded-xl bg-surface-2/70 px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-text-muted font-semibold"></div>
<div className="mt-1 text-[12px] leading-5 text-text-secondary">
{action.risk}
</div>
</div>
@ -348,7 +345,6 @@ function SectorCard({ sector, index }: { sector: SectorData; index: number }) {
</div>
</div>
) : null}
</div>
</div>
);
}
@ -362,104 +358,8 @@ function MetricBox({ label, children }: { label: string; children: React.ReactNo
);
}
function RotationPanel({ data }: { data: SectorRotationData }) {
const [el, setEl] = useState<HTMLDivElement | null>(null);
const { theme } = useNextTheme();
useEffect(() => {
if (!el || !data.sectors.length) return;
let chart: ReturnType<typeof import("echarts")["init"]> | null = null;
import("echarts").then((ec) => {
if (!el) return;
const isDark = theme !== "light";
chart = ec.init(el, isDark ? "dark" : undefined);
const isLight = theme === "light";
const axisLabelColor = isLight ? "#6b7280" : "#94a3b8";
const dates = data.dates.map((d) => d.slice(4));
const sectorNames = data.sectors.map((s) => s.sector_name);
const heatData: [number, number, number][] = [];
let minVal = Infinity;
let maxVal = -Infinity;
data.sectors.forEach((sector, yi) => {
dates.forEach((_, xi) => {
const dayData = sector.daily_data.find((item) => data.dates[xi] && item.trade_date === data.dates[xi]);
const val = dayData?.pct_change ?? 0;
heatData.push([xi, yi, val]);
if (val < minVal) minVal = val;
if (val > maxVal) maxVal = val;
});
});
chart.setOption({
backgroundColor: "transparent",
tooltip: {
formatter: (params: { data: number[] }) => {
const [x, y, val] = params.data;
return `${sectorNames[y]}<br/>${dates[x]}: <b>${val > 0 ? "+" : ""}${val.toFixed(2)}%</b>`;
},
},
grid: { left: "15%", right: "5%", top: "5%", bottom: "12%" },
xAxis: {
type: "category",
data: dates,
splitArea: { show: true },
axisLabel: { fontSize: 10, color: axisLabelColor },
},
yAxis: {
type: "category",
data: sectorNames,
axisLabel: { fontSize: 10, color: axisLabelColor, width: 60, overflow: "truncate" },
},
visualMap: {
min: minVal,
max: maxVal,
calculable: true,
orient: "horizontal",
left: "center",
bottom: 0,
inRange: {
color: ["#22c55e", "#fbbf24", "#ef4444"],
},
textStyle: { fontSize: 10, color: axisLabelColor },
},
series: [{
type: "heatmap",
data: heatData,
label: {
show: true,
fontSize: 9,
formatter: (params: { data: number[] }) => {
const val = params.data[2];
return val > 0 ? `+${val.toFixed(1)}` : val.toFixed(1);
},
},
}],
});
const handleResize = () => chart?.resize();
window.addEventListener("resize", handleResize);
});
return () => { chart?.dispose(); };
}, [data, theme, el]);
return (
<div className="glass-card-static p-4">
<div className="text-sm font-semibold text-text-primary">{data.dates.length}</div>
<div className="mt-1 text-xs text-text-muted">线使</div>
<div ref={setEl} className="mt-3 w-full" style={{ height: Math.max(data.sectors.length * 28 + 60, 200) }} />
</div>
);
}
export default function SectorsPage() {
const [sectors, setSectors] = useState<SectorData[]>([]);
const [showRotation, setShowRotation] = useState(false);
const [rotationData, setRotationData] = useState<SectorRotationData | null>(null);
const [stageFilter, setStageFilter] = useState<string>("all");
const loadData = useCallback(async () => {
@ -481,29 +381,9 @@ export default function SectorsPage() {
}, [loadData])
);
const loadRotation = useCallback(async () => {
try {
const data = await fetchAPI<SectorRotationData>("/api/sectors/rotation?days=5");
setRotationData(data);
} catch {
// ignore
}
}, []);
useEffect(() => {
if (showRotation && !rotationData) {
loadRotation();
}
}, [showRotation, rotationData, loadRotation]);
const hasRealtime = sectors.some((item) => item.is_realtime);
const structureTradeDate = sectors[0]?.structure_trade_date || sectors[0]?.trade_date || "";
const dataMode = sectors[0]?.data_mode || "daily_snapshot";
const source = sectors[0]?.source || "snapshot";
const summary = getHeadline(sectors);
const topPct = sectors[0] ? (sectors[0].realtime_pct_change ?? sectors[0].pct_change) : 0;
const hasPositiveLeader = topPct > 0;
const sourceRiskHint = getSourceRiskHint(source, dataMode);
const stageCounts = useMemo(() => {
const counts = { all: sectors.length, early: 0, mid: 0, late_end: 0 };
@ -530,26 +410,12 @@ export default function SectorsPage() {
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
<div className="animate-fade-in-up">
<h1 className="text-lg font-bold tracking-tight">线</h1>
<div className="mt-2 inline-flex items-center gap-2 rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-2 text-[11px] text-text-secondary">
<span className="text-text-muted"></span>
<span className="font-medium text-text-primary">{getSourceLabel(source)}</span>
<span className="text-text-muted/50">·</span>
<span className="text-text-muted">{dataMode}</span>
{hasRealtime ? <span className="text-emerald-400/70"></span> : null}
<span className="text-text-muted/50">·</span>
<span className="text-amber-400/80">{sourceRiskHint}</span>
</div>
{hasRealtime && dataMode === "realtime_overlay" ? (
<p className="mt-2 text-[11px] text-text-muted/70">
{structureTradeDate || "最近交易日"}
</p>
) : null}
</div>
{!sectors.length ? (
<div className="glass-card-static p-12 text-center animate-fade-in-up">
<div className="text-sm text-text-muted"></div>
<div className="mt-1 text-xs text-text-muted/50"></div>
<div className="text-sm text-text-muted">线</div>
<div className="mt-1 text-xs text-text-muted/50"></div>
</div>
) : (
<>
@ -569,12 +435,12 @@ export default function SectorsPage() {
<MetricBox label="主线数"><span className="font-mono tabular-nums text-text-primary">{mainline.length}</span></MetricBox>
<MetricBox label="次主线"><span className="font-mono tabular-nums text-text-primary">{secondary.length}</span></MetricBox>
<MetricBox label="观察线"><span className="font-mono tabular-nums text-text-primary">{watchline.length}</span></MetricBox>
<MetricBox label="实时模式"><span className="text-text-secondary">{hasRealtime ? "开启" : "关闭"}</span></MetricBox>
<MetricBox label="强催化"><span className="font-mono tabular-nums text-amber-400">{sectors.filter((sector) => (sector.catalyst_score ?? 0) >= 70).length}</span></MetricBox>
</div>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 animate-fade-in-up">
<div className="grid grid-cols-1 xl:grid-cols-[1.15fr_0.95fr_0.8fr] gap-4 animate-fade-in-up">
<LaneCard
title={hasPositiveLeader ? "今日主线" : "相对抗跌"}
description={hasPositiveLeader ? "优先方向" : "暂不进攻"}
@ -599,7 +465,7 @@ export default function SectorsPage() {
<div className="glass-card-static p-4 md:p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-text-primary"></h2>
<h2 className="text-sm font-semibold text-text-primary"></h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{[
@ -629,7 +495,7 @@ export default function SectorsPage() {
</div>
) : (
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
{filteredSectors.map((sector, index) => (
<SectorCard key={sector.sector_code} sector={sector} index={index} />
))}
@ -637,35 +503,13 @@ export default function SectorsPage() {
)}
</div>
<div className="space-y-4">
<div className="glass-card-static p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-text-primary"></h2>
</div>
<button
onClick={() => setShowRotation((value) => !value)}
className={`rounded-xl border px-3 py-2 text-xs font-medium transition-all ${
showRotation
? "border-amber-500/20 bg-amber-500/[0.06] text-amber-400"
: "border-transparent bg-surface-2 text-text-muted hover:text-text-secondary"
}`}
>
{showRotation ? "收起" : "展开"}
</button>
</div>
<div className="glass-card-static p-4 self-start">
<h2 className="text-sm font-semibold text-text-primary">使</h2>
<div className="mt-3 space-y-2 text-sm leading-6 text-text-secondary">
<div>线</div>
<div></div>
<div>线</div>
</div>
{showRotation ? (
rotationData && rotationData.sectors.length > 0 ? (
<RotationPanel data={rotationData} />
) : (
<div className="glass-card-static p-8 text-center">
<div className="mx-auto mb-2 h-6 w-6 rounded-full border-2 border-amber-400/30 border-t-amber-400 animate-spin" />
<div className="text-xs text-text-muted">...</div>
</div>
)
) : null}
</div>
</div>
</>
@ -674,9 +518,3 @@ export default function SectorsPage() {
</ErrorBoundary>
);
}
function useNextTheme() {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { useTheme } = require("next-themes");
return useTheme();
}

View File

@ -162,7 +162,7 @@ export default function StockDetailPage() {
const latestTracking = thesis?.latest_tracking;
const latestFlow = capitalFlow.length > 0 ? capitalFlow[capitalFlow.length - 1] : null;
const pageName = recommendation?.name || thesis?.name || quote?.name || code;
const aiConviction = recommendation?.llm_score != null ? Math.round(recommendation.llm_score) : null;
const conviction = recommendation?.llm_score != null ? Math.round(recommendation.llm_score) : null;
return (
<ErrorBoundary>
@ -212,14 +212,14 @@ export default function StockDetailPage() {
</div>
<div className="rounded-2xl bg-surface-1/80 border border-border-subtle p-4">
<SectionTitle title="数据状态" />
<SectionTitle title="今日处理" />
<div className="text-xs text-text-secondary leading-relaxed mt-2">
{thesis?.data_freshness.message ?? "加载中"}
{thesis ? "已读取最近推荐、跟踪和诊断记录。" : "加载中"}
</div>
<div className="grid grid-cols-2 gap-2 mt-3">
<MiniDataCell label="当前动作" value={recommendation?.action_plan || "观察"} />
<MiniDataCell label="AI 置信" value={aiConviction != null ? `${aiConviction}/10` : "暂无"} />
<MiniDataCell label="AI预筛" value={recommendation?.prefilter_decision || "暂无"} />
<MiniDataCell label="把握度" value={conviction != null ? `${conviction}/10` : "暂无"} />
<MiniDataCell label="初步判断" value={formatPrefilterDecision(recommendation?.prefilter_decision)} />
<MiniDataCell label="建议仓位" value={recommendation?.suggested_position_pct != null ? `${recommendation.suggested_position_pct}%` : "未设置"} />
</div>
{(recommendation?.recall_tags?.length ?? 0) > 0 ? (
@ -232,7 +232,7 @@ export default function StockDetailPage() {
</div>
) : null}
<div className="flex flex-wrap items-center gap-3 mt-3 text-[11px] text-text-muted">
<span> {formatDateTime(thesis?.data_freshness.recommendation_created_at)}</span>
<span> {formatDateTime(thesis?.data_freshness.recommendation_created_at)}</span>
<span> {thesis?.data_freshness.tracking_date || "暂无"}</span>
<span> {thesis?.diagnoses?.length ? `${thesis.diagnoses.length}` : "暂无"}</span>
</div>
@ -240,7 +240,7 @@ export default function StockDetailPage() {
href={`/diagnose?code=${code}`}
className="inline-flex items-center justify-center mt-3 w-full text-xs px-3 py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 transition-all border border-amber-500/10"
>
AI
</a>
</div>
</div>
@ -280,8 +280,8 @@ export default function StockDetailPage() {
{evidenceLoaded ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-fade-in-up">
{kline.length > 0 ? <KlineChart data={kline as never[]} /> : <ChartEmptyCard title="K线图" description="暂无K线缓存数据" />}
{capitalFlow.length > 0 ? <CapitalFlowChart data={capitalFlow} /> : <ChartEmptyCard title="资金流向趋势" description="暂无资金流缓存数据" />}
{kline.length > 0 ? <KlineChart data={kline as never[]} /> : <ChartEmptyCard title="K线图" description="暂无K线数据" />}
{capitalFlow.length > 0 ? <CapitalFlowChart data={capitalFlow} /> : <ChartEmptyCard title="资金流向趋势" description="暂无资金流数据" />}
</div>
{latestFlow ? (
@ -308,19 +308,19 @@ function PlanCard({
<div className="flex items-center justify-between gap-3 mb-3">
<SectionTitle title="执行计划" />
{recommendation?.llm_score != null ? (
<span className="text-xs font-mono tabular-nums text-cyan-400/80">AI {Math.round(recommendation.llm_score)}/10</span>
<span className="text-xs font-mono tabular-nums text-cyan-400/80"> {Math.round(recommendation.llm_score)}/10</span>
) : null}
</div>
<div className="space-y-3 text-sm">
{recommendation?.prefilter_reason ? <PlanRow label="AI预筛原因" value={recommendation.prefilter_reason} /> : null}
{recommendation?.prefilter_reason ? <PlanRow label="初筛理由" value={recommendation.prefilter_reason} /> : null}
{(recommendation?.focus_points?.length ?? 0) > 0 ? (
<PlanRow label="AI关注点" value={(recommendation?.focus_points ?? []).slice(0, 3).join(" / ")} />
<PlanRow label="关注点" value={(recommendation?.focus_points ?? []).slice(0, 3).join(" / ")} />
) : null}
<PlanRow label="触发条件" value={recommendation?.trigger_condition || "暂无明确触发条件"} />
<PlanRow label="失效条件" value={recommendation?.invalidation_condition || "暂无明确失效条件"} />
<PlanRow label="建议仓位" value={recommendation?.suggested_position_pct != null ? `${recommendation.suggested_position_pct}%` : "未设置"} />
<PlanRow label="复盘周期" value={recommendation?.review_after_days ? `${recommendation.review_after_days}个交易日` : "未设置"} />
{recommendation ? <PlanRow label="召回来源" value={(recommendation.recall_tags ?? []).map(formatRecallTag).join(" / ") || "暂无归档"} /> : null}
{recommendation ? <PlanRow label="入选线索" value={(recommendation.recall_tags ?? []).map(formatRecallTag).join(" / ") || "暂无归档"} /> : null}
{recommendation ? <PlanRow label="规则参考" value={`${Math.round(recommendation.score)} 分(边界证据)`} /> : null}
{trackingNote ? <PlanRow label="跟踪结论" value={trackingNote} /> : null}
</div>
@ -330,8 +330,8 @@ function PlanCard({
function formatRecallTag(tag: string): string {
const labels: Record<string, string> = {
sector_recall: "主线召回",
trend_scan: "趋势召回",
sector_recall: "主线入选",
trend_scan: "趋势入选",
intraday_active: "盘中异动",
hot_sector_core: "板块核心",
sector_leader: "前排线索",
@ -341,6 +341,15 @@ function formatRecallTag(tag: string): string {
return labels[tag] ?? tag;
}
function formatPrefilterDecision(decision?: string | null): string {
const labels: Record<string, string> = {
priority: "优先研究",
watch: "保留观察",
ignore: "暂不处理",
};
return labels[decision ?? ""] ?? "暂无";
}
function EvidenceCard({
recommendation,
quote,
@ -394,7 +403,7 @@ function DiagnosisArchiveCard({ diagnoses }: { diagnoses: StockThesisResponse["d
return (
<div className="glass-card-static p-5">
<div className="flex items-center justify-between gap-3 mb-3">
<SectionTitle title="AI 推演归档" />
<SectionTitle title="推演归档" />
<span className="text-[10px] text-text-muted font-mono tabular-nums">{diagnoses.length}</span>
</div>
{diagnoses.length ? (
@ -409,7 +418,7 @@ function DiagnosisArchiveCard({ diagnoses }: { diagnoses: StockThesisResponse["d
))}
</div>
) : (
<div className="text-sm text-text-muted"> AI </div>
<div className="text-sm text-text-muted"></div>
)}
</div>
);
@ -441,7 +450,7 @@ function TrackingCard({ tracking }: { tracking: StockThesisResponse["latest_trac
function QuoteSnapshot({ quote, evidenceLoaded }: { quote: QuoteData | null; evidenceLoaded: boolean }) {
return (
<div className="glass-card-static p-5">
<SectionTitle title="盘面快照" />
<SectionTitle title="盘面表现" />
{quote ? (
<>
<div className="flex items-baseline gap-3 mt-3">
@ -460,7 +469,7 @@ function QuoteSnapshot({ quote, evidenceLoaded }: { quote: QuoteData | null; evi
</div>
</>
) : evidenceLoaded ? (
<div className="text-sm text-text-muted mt-3"></div>
<div className="text-sm text-text-muted mt-3"></div>
) : (
<div className="text-sm text-text-muted mt-3"></div>
)}
@ -495,7 +504,7 @@ function SignalSnapshot({
</div>
</>
) : evidenceLoaded ? (
<div className="text-sm text-text-muted mt-3"></div>
<div className="text-sm text-text-muted mt-3"></div>
) : (
<div className="text-sm text-text-muted mt-3"></div>
)}

View File

@ -357,8 +357,19 @@ function ConfigCenterPanel({
function StrategyConfigCard({ item, onRollback }: { item: StrategyConfigRecord; onRollback: (strategyId: string) => void }) {
const cfg = item.config;
const scoreWeights = cfg.score_weights as Record<string, number> | undefined;
const weightLabels: Record<string, string> = {
catalyst: "催化",
theme_money: "主题资金",
stock_money: "个股资金",
emotion_role: "情绪角色",
timing: "时机",
capital_momentum: "资金顺势",
supply_demand: "供需",
price_action: "价格行为",
trend: "趋势",
};
const weightText = scoreWeights
? Object.entries(scoreWeights).map(([key, value]) => `${key}:${Number(value).toFixed(2)}`).join(" / ")
? Object.entries(scoreWeights).map(([key, value]) => `${weightLabels[key] ?? key}:${Number(value).toFixed(2)}`).join(" / ")
: "暂无权重";
return (

View File

@ -113,12 +113,12 @@ export default function LoginPage() {
<div className="flex flex-col items-center mb-8">
<div className="relative mb-5">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-lg font-bold text-white shadow-glow">
D
A
</div>
<div className="absolute inset-0 rounded-xl border border-amber-500/20 animate-pulse-ring" />
</div>
<h1 className="text-lg font-bold tracking-tight text-text-primary">Dragon AI Agent</h1>
<p className="text-xs text-text-muted mt-1">A </p>
<h1 className="text-lg font-bold tracking-tight text-text-primary">AlphaX Agent</h1>
<p className="text-xs text-text-muted mt-1">Stock</p>
</div>
<div className="glass-card-static p-7 rounded-2xl">

View File

@ -17,11 +17,11 @@ export default function LandingPage() {
<header className="flex items-center justify-between py-6 animate-fade-in-up">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-sm font-bold text-white shadow-glow-sm">
D
A
</div>
<div>
<div className="text-sm font-semibold tracking-tight">Dragon AI Agent</div>
<div className="text-xs text-text-muted mt-0.5">A AI </div>
<div className="text-sm font-semibold tracking-tight">AlphaX Agent</div>
<div className="text-xs text-text-muted mt-0.5">Stock</div>
</div>
</div>
@ -36,7 +36,7 @@ export default function LandingPage() {
<section className="flex-1 flex items-center py-12 md:py-20">
<div className="w-full max-w-4xl">
<div className="text-[10px] uppercase tracking-[0.24em] text-amber-400 font-semibold animate-fade-in-up">
AI Market Operating System
AlphaX Agent Stock
</div>
<h1 className="mt-5 text-4xl md:text-6xl font-bold tracking-tight leading-[1.02] animate-fade-in-up">
@ -47,7 +47,7 @@ export default function LandingPage() {
<p className="mt-6 max-w-2xl text-base md:text-lg text-text-secondary leading-relaxed animate-fade-in-up">
线
AI
</p>
<div className="flex flex-wrap gap-3 mt-8 animate-fade-in-up">

View File

@ -4,8 +4,8 @@ import { AuthProvider } from "@/hooks/use-auth";
import { ThemeProvider } from "next-themes";
export const metadata: Metadata = {
title: "Dragon AI Agent",
description: "A 股智能筛选引擎,盘中实时分析与推荐",
title: "AlphaX Agent | Stock",
description: "A 股投研作战台,聚焦市场主线、资金流与交易决策",
};
export const viewport: Viewport = {

View File

@ -94,7 +94,7 @@ export default function MarketTemp({ data, indices }: MarketTempProps) {
{!limitCountsReliable && (
<div className="text-[10px] text-text-muted/60 mb-2">
/ 0
/
</div>
)}
@ -105,7 +105,6 @@ export default function MarketTemp({ data, indices }: MarketTempProps) {
<div key={idx.code} className="bg-surface-1 rounded-lg px-2 sm:px-3 py-2 border border-border-subtle">
<div className="text-[10px] sm:text-xs text-text-muted mb-0.5 truncate">
{idx.name}
{idx.realtime && <span className="text-emerald-400/60 ml-0.5">·</span>}
</div>
<div className="flex flex-col sm:flex-row sm:items-baseline gap-0.5 sm:gap-1.5">
<span className={`text-xs sm:text-sm font-mono tabular-nums font-semibold ${idx.pct_chg > 0 ? "text-red-400" : idx.pct_chg < 0 ? "text-emerald-400" : "text-text-primary"}`}>

View File

@ -118,12 +118,12 @@ export function SidebarNav() {
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐池" />
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块主线" />
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" />
<SideNavItem href="/chat" icon={<ChatIcon />} label="系统智能体" />
<SideNavItem href="/chat" icon={<ChatIcon />} label="研究助手" />
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="个股诊断" />
{user?.role === "admin" && (
<>
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="系统校准" />
<SideNavItem href="/settings" icon={<SettingsIcon />} label="系统设置" />
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="策略校准" />
<SideNavItem href="/settings" icon={<SettingsIcon />} label="管理设置" />
</>
)}
</nav>
@ -159,7 +159,7 @@ export function MobileBottomNav() {
<MobileNavItem href="/recommendations" label="推荐池">
<TargetIcon />
</MobileNavItem>
<MobileNavItem href="/chat" label="智能体">
<MobileNavItem href="/chat" label="助手">
<ChatIcon />
</MobileNavItem>
<MobileNavItem href="/watchlists" label="自选">

View File

@ -36,19 +36,12 @@ export default function SectorHeatmap({ sectors }: { sectors: SectorData[] }) {
}
const mainSectors = sectors.slice(0, 3);
const hasRealtime = sectors.some((s) => s.is_realtime);
return (
<div className="glass-card-static p-4 sm:p-5 animate-fade-in-up">
<div className="flex items-start justify-between gap-3 mb-4">
<div>
<h2 className="text-sm font-semibold text-text-muted">线</h2>
</div>
{hasRealtime && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400/80">
</span>
)}
</div>
<div className="space-y-3">
@ -104,6 +97,11 @@ export default function SectorHeatmap({ sectors }: { sectors: SectorData[] }) {
{sector.stage}
</span>
) : null}
{(sector.catalyst_score ?? 0) >= 45 ? (
<span className="px-2 py-1 rounded-lg bg-amber-500/10 text-amber-300">
{(sector.catalyst_score ?? 0).toFixed(0)}
</span>
) : null}
{leaders[0] ? (
<span className="px-2 py-1 rounded-lg bg-surface-2 text-text-secondary">
{leaders.slice(0, 2).map((item) => item.name).join(" / ")}

View File

@ -1,314 +1,115 @@
"use client";
import { getLevelBadge } from "@/lib/utils";
import type { RecommendationData } from "@/lib/api";
export default function StockCard({ rec, compact = false }: { rec: RecommendationData; compact?: boolean }) {
const badge = getLevelBadge(rec.level);
const aiConviction = rec.llm_score != null ? Math.round(rec.llm_score) : null;
const action = getActionMeta(rec.action_plan);
const trigger = rec.trigger_condition ?? rec.entry_timing ?? rec.decision_trace?.headline ?? rec.prefilter_reason ?? rec.reasons?.[0] ?? "等待触发条件确认";
const risk = rec.invalidation_condition ?? rec.risk_note ?? "暂无明确失效条件";
const thesis = rec.decision_trace?.headline ?? rec.reasons?.[0] ?? rec.focus_points?.[0] ?? "等待更多盘面证据";
const conviction = rec.llm_score != null ? Math.round(rec.llm_score) : null;
const chips = buildChips(rec, conviction).slice(0, compact ? 3 : 5);
return (
<a
href={`/stock/${rec.ts_code}`}
className="group block rounded-2xl border border-border-subtle bg-surface-1/80 p-3.5 transition-all hover:border-amber-500/25 hover:bg-surface-2/80"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate text-[15px] font-semibold tracking-tight text-text-primary">{rec.name}</span>
<span className={`rounded-lg border px-2 py-0.5 text-[10px] font-medium ${action.className}`}>
{action.label}
</span>
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5 text-[11px] text-text-muted">
<span className="font-mono tabular-nums">{rec.ts_code}</span>
{rec.sector ? (
<span className="max-w-[9rem] truncate rounded-md bg-surface-2 px-1.5 py-0.5 text-text-secondary">
{rec.sector}
</span>
) : null}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-[10px] text-text-muted"></div>
<div className="mt-0.5 font-mono text-sm font-semibold tabular-nums text-text-primary">
{rec.suggested_position_pct != null ? `${rec.suggested_position_pct}%` : "--"}
</div>
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-2">
<ActionRow label="触发" value={trigger} tone="trigger" />
<ActionRow label="失效" value={risk} tone="risk" />
</div>
{!compact ? (
<div className="mt-3 rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-wider text-text-muted"></div>
<div className="mt-1 text-xs leading-5 text-text-secondary line-clamp-2">{thesis}</div>
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-1.5">
{chips.map((chip) => (
<span key={chip} className="rounded-md bg-surface-2 px-2 py-1 text-[10px] text-text-muted">
{chip}
</span>
))}
</div>
</a>
);
}
function ActionRow({ label, value, tone }: { label: string; value: string; tone: "trigger" | "risk" }) {
const toneClass = tone === "trigger" ? "text-emerald-400" : "text-amber-400";
return (
<div className="grid grid-cols-[2.5rem_minmax(0,1fr)] gap-2 rounded-xl bg-surface-2/70 px-3 py-2">
<span className={`text-[11px] font-semibold ${toneClass}`}>{label}</span>
<span className="min-w-0 text-xs leading-5 text-text-secondary line-clamp-2">{value}</span>
</div>
);
}
function getActionMeta(actionPlan?: string | null) {
if (actionPlan === "可操作") {
return { label: "可操作", className: "border-red-500/20 bg-red-500/10 text-red-400" };
}
if (actionPlan === "重点关注") {
return { label: "重点关注", className: "border-amber-500/20 bg-amber-500/10 text-amber-400" };
}
return { label: "观察", className: "border-border-subtle bg-surface-2 text-text-muted" };
}
function buildChips(rec: RecommendationData, conviction: number | null) {
const recallLabels: Record<string, string> = {
sector_recall: "主线召回",
trend_scan: "趋势召回",
sector_recall: "主线入选",
trend_scan: "趋势入选",
intraday_active: "盘中异动",
hot_theme_core: "主题核心",
theme_leader: "主题前排",
top_theme_member: "主线主题成分",
top_theme_member: "主线成分",
moneyflow_support: "资金支撑",
volume_active: "量能活跃",
};
const prefilterLabel: Record<string, string> = {
priority: "AI优先深看",
watch: "AI保留观察",
ignore: "AI建议忽略",
"": "待AI预筛",
};
// 入场信号标签
const signalTypeMap: Record<string, { label: string; style: string }> = {
breakout: { label: "突破型", style: "bg-red-500/15 text-red-400 border-red-500/20" },
pullback: { label: "回踩型", style: "bg-blue-500/15 text-blue-400 border-blue-500/20" },
launch: { label: "启动型", style: "bg-orange-500/15 text-orange-400 border-orange-500/20" },
};
// 向后兼容:旧数据使用 strategy 字段
const signalInfo = signalTypeMap[rec.entry_signal_type || ""];
const legacyStrategy = rec.strategy === "potential"
? { label: "潜在启动", style: "bg-cyan-500/15 text-cyan-400 border-cyan-500/20" }
: rec.strategy === "momentum"
? { label: "强中选强", style: "bg-amber-500/15 text-amber-400 border-amber-500/20" }
: null;
const tag = signalInfo || legacyStrategy;
const actionPlanStyle: Record<string, string> = {
"可操作": "bg-red-500/15 text-red-400 border-red-500/20",
"重点关注": "bg-amber-500/15 text-amber-400 border-amber-500/20",
"观察": "bg-surface-3 text-text-muted border-border-default",
};
const lifecycleLabel: Record<string, string> = {
candidate: "观察池",
actionable: "可操作",
tracking: "跟踪中",
closed_win: "盈利结束",
closed_loss: "亏损结束",
expired: "到期复盘",
invalidated: "已失效",
};
const actionPlanCopy: Record<string, string> = {
"可操作": "触发后执行",
"重点关注": "等待确认",
"观察": "仅观察",
};
const evidence = [
rec.prefilter_reason,
rec.focus_points?.[0],
rec.reasons?.[0],
rec.entry_timing,
rec.data_freshness,
].filter(Boolean).slice(0, 3) as string[];
const headline = rec.trigger_condition ?? rec.entry_timing ?? rec.prefilter_reason ?? rec.reasons?.[0] ?? "等待触发条件确认";
const riskLine = rec.invalidation_condition ?? rec.risk_note ?? "";
const recallSummary = (rec.recall_tags ?? []).slice(0, compact ? 2 : 3);
return (
<div className="glass-card p-4 group">
{/* Clickable top section — navigates to stock detail */}
<a href={`/stock/${rec.ts_code}`} className="block">
{/* Header: Name + Action state */}
<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>
)}
{rec.action_plan && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium border ${actionPlanStyle[rec.action_plan] ?? actionPlanStyle["观察"]}`}>
{rec.action_plan}
</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-[10px] text-text-muted uppercase tracking-wider"></div>
<div className="text-xs text-text-secondary mt-0.5">{rec.action_plan ?? "观察"}</div>
{aiConviction != null ? (
<div className="text-[10px] font-mono tabular-nums text-cyan-400/80 mt-0.5">
AI {aiConviction}/10
</div>
) : null}
</div>
</div>
{(rec.action_plan || rec.trigger_condition || rec.invalidation_condition || rec.suggested_position_pct) && (
<div className="mb-3 rounded-xl bg-surface-1/70 border border-border-subtle p-3">
<div className="flex items-center justify-between gap-2 mb-2">
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">AI </div>
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${rec.action_plan ? actionPlanStyle[rec.action_plan] ?? actionPlanStyle["观察"] : actionPlanStyle["观察"]}`}>
{rec.action_plan ? actionPlanCopy[rec.action_plan] ?? rec.action_plan : "等待结论"}
</span>
</div>
{rec.trigger_condition && (
<div className="text-[11px] text-text-secondary leading-relaxed line-clamp-2">
{rec.trigger_condition}
</div>
)}
{rec.invalidation_condition && (
<div className="text-[11px] text-text-muted leading-relaxed line-clamp-2 mt-1">
{rec.invalidation_condition}
</div>
)}
<div className="flex flex-wrap items-center gap-2 mt-2 text-[10px] text-text-muted">
{rec.suggested_position_pct != null && (
<span className="rounded-md bg-surface-2 px-2 py-1">
{rec.suggested_position_pct}%
</span>
)}
{rec.review_after_days ? (
<span className="rounded-md bg-surface-2 px-2 py-1">
{rec.review_after_days}
</span>
) : null}
{aiConviction != null ? (
<span className="rounded-md bg-cyan-500/[0.06] border border-cyan-500/10 px-2 py-1 text-cyan-400/80">
AI置信 {aiConviction}/10
</span>
) : rec.score ? (
<span className="rounded-md bg-surface-2 px-2 py-1 text-text-secondary">
{rec.score.toFixed(0)}
</span>
) : null}
<span className={`rounded-md px-2 py-1 ${badge.bg} ${badge.text}`}>
{rec.level}
</span>
</div>
</div>
)}
{compact ? (
<div className="mb-3 rounded-xl bg-surface-1/60 border border-border-subtle px-3 py-2.5">
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold mb-2"></div>
<div className="text-[12px] text-text-secondary leading-relaxed">{headline}</div>
{riskLine ? (
<div className="mt-2 text-[11px] text-text-muted leading-relaxed">
{riskLine}
</div>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] text-text-muted">
{rec.suggested_position_pct != null ? (
<span className="rounded-md bg-surface-2 px-2 py-1"> {rec.suggested_position_pct}%</span>
) : null}
{rec.review_after_days ? (
<span className="rounded-md bg-surface-2 px-2 py-1">{rec.review_after_days}</span>
) : null}
{recallSummary.map((tag) => (
<span key={`${rec.ts_code}-${tag}`} className="rounded-md bg-surface-2 px-2 py-1">
{recallLabels[tag] ?? tag}
</span>
))}
</div>
</div>
) : evidence.length > 0 && (
<div className="mb-3 rounded-xl bg-surface-1/60 border border-border-subtle px-3 py-2.5">
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold mb-2">AI </div>
<div className="space-y-1.5">
{evidence.map((item, index) => (
<div key={`${rec.ts_code}-evidence-${index}`} className="text-[11px] 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">{item}</span>
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-2 mt-2.5 text-[10px] text-text-muted">
{(rec.recall_tags ?? []).slice(0, 3).map((tag) => (
<span key={`${rec.ts_code}-${tag}`} className="rounded-md bg-surface-2 px-2 py-1">
{recallLabels[tag] ?? tag}
</span>
))}
<span className="rounded-md bg-cyan-500/[0.06] border border-cyan-500/10 px-2 py-1 text-cyan-400/80">
{prefilterLabel[rec.prefilter_decision ?? ""] ?? "AI预筛"}
</span>
</div>
</div>
)}
{!compact && (rec.focus_points?.length ?? 0) > 0 && (
<div className="mb-3 rounded-xl bg-surface-1/60 border border-border-subtle px-3 py-2.5">
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold mb-2"></div>
<div className="space-y-1.5">
{(rec.focus_points ?? []).slice(0, 3).map((item, index) => (
<div key={`${rec.ts_code}-focus-${index}`} className="text-[11px] text-text-secondary flex items-start gap-2">
<span className="w-1 h-1 rounded-full bg-cyan-400/70 mt-[6px] shrink-0" />
<span className="leading-relaxed line-clamp-2">{item}</span>
</div>
))}
</div>
</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>
)}
{!compact && rec.tracking && (
<div className="mb-3 bg-surface-1/60 rounded-lg px-3 py-2 border border-border-subtle">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] text-text-muted">
· {lifecycleLabel[rec.lifecycle_status || ""] ?? rec.lifecycle_status ?? "跟踪"}
</span>
<span className="text-[10px] text-text-muted font-mono tabular-nums">
{rec.tracking.days_since_recommendation ?? 0} · {rec.tracking.track_date}
</span>
</div>
<div className="grid grid-cols-3 gap-2">
<TrackingMetric
label="当前"
value={rec.tracking.pct_from_entry}
/>
<TrackingMetric
label="最大浮盈"
value={rec.tracking.max_return_pct}
/>
<TrackingMetric
label="最大回撤"
value={rec.tracking.max_drawdown_pct}
/>
</div>
{rec.tracking.review_note && (
<div className="text-[11px] text-text-muted leading-relaxed mt-2 line-clamp-2">
{rec.tracking.review_note}
</div>
)}
</div>
)}
{/* Reasons */}
{!compact && (
<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>
<div className="mt-3 border-t border-border-subtle pt-2 flex items-center justify-between gap-3 text-[11px]">
<div className="text-text-muted">
{compact ? "更多推演进入详情页" : "召回、预筛与推演链路已归档"}
{aiConviction != null && (
<span className="ml-2 font-mono tabular-nums text-cyan-400/80">
AI {aiConviction}/10
</span>
)}
</div>
<a href={`/stock/${rec.ts_code}`} className="shrink-0 text-cyan-400/80 hover:text-cyan-400 transition-colors">
</a>
</div>
{/* Risk note */}
{!compact && 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>
)}
</div>
);
return [
rec.entry_signal_type ? signalTypeLabel(rec.entry_signal_type) : null,
rec.review_after_days ? `${rec.review_after_days}日复盘` : null,
conviction != null ? `把握 ${conviction}/10` : null,
...(rec.recall_tags ?? []).map((tag) => recallLabels[tag] ?? tag),
].filter(Boolean) as string[];
}
function TrackingMetric({ label, value }: { label: string; value: number | null }) {
const num = value ?? 0;
const color = num > 0 ? "text-red-400" : num < 0 ? "text-emerald-400" : "text-text-secondary";
return (
<div>
<div className="text-[10px] text-text-muted/60 mb-0.5">{label}</div>
<div className={`text-xs font-mono tabular-nums font-semibold ${color}`}>
{num > 0 ? "+" : ""}{num.toFixed(2)}%
</div>
</div>
);
function signalTypeLabel(type: string) {
const labels: Record<string, string> = {
breakout: "突破",
pullback: "回踩",
launch: "启动",
};
return labels[type] ?? type;
}

View File

@ -144,6 +144,7 @@ export interface RecommendationData {
prefilter_decision?: "priority" | "watch" | "ignore" | "";
prefilter_reason?: string;
focus_points?: string[];
decision_trace?: DecisionTrace;
scan_session: string;
created_at: string | null;
entry_timing?: string;
@ -157,6 +158,34 @@ export interface RecommendationData {
tracking?: RecommendationTrackingSummary | null;
}
export interface DecisionTrace {
version?: number;
headline?: string;
action_plan?: string;
final_score?: number;
route_tags?: string[];
evidence?: string[];
score_breakdown?: Array<{
key: string;
label: string;
score: number;
weight?: number;
}>;
boosts?: Array<{ label: string; value?: string; reason?: string }>;
penalties?: Array<{ label: string; value?: string; reason?: string }>;
risk_tags?: string[];
catalyst?: {
score?: number;
reasons?: string[];
};
llm_adjustment?: {
verdict?: string;
action_plan?: string;
conviction?: number;
reason?: string;
} | null;
}
export interface RecommendationTrackingSummary {
current_price: number | null;
pct_from_entry: number | null;
@ -206,6 +235,11 @@ export interface SectorData {
data_mode?: "realtime_today" | "realtime_overlay" | "daily_snapshot";
structure_trade_date?: string;
source?: "eastmoney" | "sina" | "snapshot" | string;
data_status?: "fresh" | "stale" | "fallback" | "snapshot" | "mixed" | string;
source_detail?: string;
catalyst_score?: number;
catalyst_count?: number;
catalyst_reasons?: string[];
}
export interface LatestResult {