update
This commit is contained in:
parent
bdaaa83bf6
commit
d6bae2c8b6
@ -5,14 +5,17 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from app.config import should_prefer_realtime_today, today_trade_date
|
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.eastmoney_client import get_sector_realtime_ranking
|
||||||
from app.data.models import SectorInfo
|
from app.data.models import SectorInfo
|
||||||
from app.data.tushare_client import tushare_client
|
from app.data.tushare_client import tushare_client
|
||||||
from app.analysis.theme_mapper import merge_sectors_to_themes
|
from app.analysis.theme_mapper import merge_sectors_to_themes
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
_today_sector_board_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
|
||||||
|
|
||||||
def _match_sector_name(em_name: str, ts_name: str) -> bool:
|
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.data_mode = "daily_snapshot"
|
||||||
sector.source = sector.source or "snapshot"
|
sector.source = sector.source or "snapshot"
|
||||||
sector.board_type = sector.board_type or "snapshot"
|
sector.board_type = sector.board_type or "snapshot"
|
||||||
|
sector.data_status = "snapshot"
|
||||||
|
sector.source_detail = "daily_snapshot"
|
||||||
return sector
|
return sector
|
||||||
|
|
||||||
|
|
||||||
def _sector_from_eastmoney(item: dict) -> SectorInfo:
|
def _sector_from_eastmoney(item: dict) -> SectorInfo:
|
||||||
"""把东方财富板块榜转换成今日展示用 SectorInfo。"""
|
"""把东方财富板块榜转换成今日展示用 SectorInfo。"""
|
||||||
|
source = item.get("source", "eastmoney")
|
||||||
sector = SectorInfo(
|
sector = SectorInfo(
|
||||||
sector_code=item.get("sector_code", ""),
|
sector_code=item.get("sector_code", ""),
|
||||||
sector_name=item.get("sector_name", ""),
|
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),
|
realtime_down_count=int(item.get("down_count", 0) or 0),
|
||||||
is_realtime=True,
|
is_realtime=True,
|
||||||
data_mode="realtime_today",
|
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"):
|
if item.get("leading_stock_name"):
|
||||||
sector.leading_stocks_realtime = [{
|
sector.leading_stocks_realtime = [{
|
||||||
@ -77,15 +85,37 @@ def _sector_from_eastmoney(item: dict) -> SectorInfo:
|
|||||||
|
|
||||||
async def get_today_realtime_sector_board(limit: int = 20) -> list[SectorInfo]:
|
async def get_today_realtime_sector_board(limit: int = 20) -> list[SectorInfo]:
|
||||||
"""用实时行业榜 + 概念榜生成展示列表,作为 Tushare/定时扫描滞后的兜底。"""
|
"""用实时行业榜 + 概念榜生成展示列表,作为 Tushare/定时扫描滞后的兜底。"""
|
||||||
industry_sectors = await get_sector_realtime_ranking(fs="m:90+t:2", page_size=max(limit, 20), notify=False)
|
cache_key = f"today_sector_board:{today_trade_date()}:{limit}"
|
||||||
concept_sectors = await get_sector_realtime_ranking(fs="m:90+t:3", page_size=max(limit, 20), notify=False)
|
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
|
em_sectors = industry_sectors + concept_sectors
|
||||||
if not em_sectors:
|
if not industry_sectors:
|
||||||
try:
|
try:
|
||||||
from app.data.sina_client import get_sector_realtime_ranking_by_industry
|
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:
|
except Exception as e:
|
||||||
logger.warning("新浪行业实时榜兜底失败: %s", e)
|
logger.warning("新浪行业实时榜兜底失败: %s", e)
|
||||||
|
if not em_sectors:
|
||||||
em_sectors = []
|
em_sectors = []
|
||||||
|
|
||||||
deduped = {}
|
deduped = {}
|
||||||
@ -100,6 +130,25 @@ async def get_today_realtime_sector_board(limit: int = 20) -> list[SectorInfo]:
|
|||||||
return sectors[:limit]
|
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]:
|
async def enrich_sectors_with_realtime(sectors: list[SectorInfo]) -> list[SectorInfo]:
|
||||||
"""按需为板块快照追加实时字段并重排。"""
|
"""按需为板块快照追加实时字段并重排。"""
|
||||||
if not sectors:
|
if not sectors:
|
||||||
@ -151,6 +200,8 @@ async def enrich_sectors_with_realtime(sectors: list[SectorInfo]) -> list[Sector
|
|||||||
sector.is_realtime = True
|
sector.is_realtime = True
|
||||||
sector.data_mode = "realtime_overlay"
|
sector.data_mode = "realtime_overlay"
|
||||||
sector.source = em_data.get("source", "eastmoney")
|
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))
|
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)
|
sectors.sort(key=lambda s: (s.realtime_pct_change if s.realtime_pct_change is not None else s.pct_change), reverse=True)
|
||||||
|
|||||||
@ -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
|
existing.is_realtime = existing.is_realtime or sector.is_realtime
|
||||||
if existing.data_mode == "daily_snapshot" and sector.data_mode != "daily_snapshot":
|
if existing.data_mode == "daily_snapshot" and sector.data_mode != "daily_snapshot":
|
||||||
existing.data_mode = sector.data_mode
|
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:
|
if existing.source != sector.source:
|
||||||
existing.source = "mixed"
|
existing.source = "mixed"
|
||||||
|
|
||||||
|
|||||||
26
backend/app/api/catalysts.py
Normal file
26
backend/app/api/catalysts.py
Normal 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()
|
||||||
@ -4,13 +4,9 @@ from datetime import datetime
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
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.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.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
|
from app.core.deps import get_current_admin
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/market", tags=["market"])
|
router = APIRouter(prefix="/api/market", tags=["market"])
|
||||||
@ -18,17 +14,9 @@ router = APIRouter(prefix="/api/market", tags=["market"])
|
|||||||
|
|
||||||
@router.get("/temperature")
|
@router.get("/temperature")
|
||||||
async def get_temperature():
|
async def get_temperature():
|
||||||
"""获取市场温度。
|
"""获取市场温度快照。页面访问只读数据库,不触发外部行情。"""
|
||||||
|
|
||||||
交易日 09:15 后优先做轻量实时计算,不触发完整扫描或 LLM。
|
|
||||||
"""
|
|
||||||
result = await get_latest_recommendations()
|
result = await get_latest_recommendations()
|
||||||
mt = result.get("market_temp")
|
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:
|
if mt:
|
||||||
return {
|
return {
|
||||||
"trade_date": mt.trade_date,
|
"trade_date": mt.trade_date,
|
||||||
@ -41,8 +29,8 @@ async def get_temperature():
|
|||||||
"broken_rate": mt.broken_rate,
|
"broken_rate": mt.broken_rate,
|
||||||
"index_above_ma20": getattr(mt, "index_above_ma20", False),
|
"index_above_ma20": getattr(mt, "index_above_ma20", False),
|
||||||
"is_trading": is_trading_hours(),
|
"is_trading": is_trading_hours(),
|
||||||
"data_mode": "realtime_today" if realtime_used else "daily_snapshot",
|
"data_mode": "daily_snapshot",
|
||||||
"limit_counts_reliable": breadth.limit_counts_reliable if breadth else False,
|
"limit_counts_reliable": False,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"trade_date": "",
|
"trade_date": "",
|
||||||
@ -60,14 +48,12 @@ async def get_temperature():
|
|||||||
|
|
||||||
@router.get("/overview")
|
@router.get("/overview")
|
||||||
async def get_overview():
|
async def get_overview():
|
||||||
"""市场概况:上证、深证、创业板指数
|
"""市场概况快照。
|
||||||
|
|
||||||
盘中用腾讯实时行情,盘后用 Tushare 日线(有缓存)。
|
页面访问不拉腾讯/Tushare。当前库里还没有指数快照表,先返回空数组。
|
||||||
|
后续应由扫描任务把指数概览写入本地表后再展示。
|
||||||
"""
|
"""
|
||||||
latest_trade_date = tushare_client.get_latest_trade_date()
|
return []
|
||||||
if should_prefer_realtime_today(latest_trade_date):
|
|
||||||
return await _overview_realtime()
|
|
||||||
return _overview_daily()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/strategy-board")
|
@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(f"market:strategy_iteration:{limit}:rules")
|
||||||
cache.delete("market:strategy_board:rules")
|
cache.delete("market:strategy_board:rules")
|
||||||
return result
|
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
|
|
||||||
|
|||||||
@ -13,8 +13,7 @@ from app.engine.recommender import (
|
|||||||
get_recommendation_history,
|
get_recommendation_history,
|
||||||
get_performance_stats,
|
get_performance_stats,
|
||||||
)
|
)
|
||||||
from app.config import is_trading_hours, should_prefer_realtime_today
|
from app.config import is_trading_hours
|
||||||
from app.data.tushare_client import tushare_client
|
|
||||||
from app.core.deps import get_current_admin
|
from app.core.deps import get_current_admin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -29,13 +28,8 @@ async def get_latest():
|
|||||||
anomalies = await get_latest_market_anomalies()
|
anomalies = await get_latest_market_anomalies()
|
||||||
|
|
||||||
mt = result.get("market_temp")
|
mt = result.get("market_temp")
|
||||||
try:
|
|
||||||
from app.api.market import get_temperature
|
|
||||||
realtime_temp = await get_temperature()
|
|
||||||
except Exception:
|
|
||||||
realtime_temp = None
|
|
||||||
return {
|
return {
|
||||||
"market_temperature": realtime_temp or ({
|
"market_temperature": ({
|
||||||
"trade_date": mt.trade_date if mt else "",
|
"trade_date": mt.trade_date if mt else "",
|
||||||
"temperature": mt.temperature if mt else 0,
|
"temperature": mt.temperature if mt else 0,
|
||||||
"up_count": mt.up_count 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_decision": r.prefilter_decision,
|
||||||
"prefilter_reason": r.prefilter_reason,
|
"prefilter_reason": r.prefilter_reason,
|
||||||
"focus_points": r.focus_points,
|
"focus_points": r.focus_points,
|
||||||
|
"decision_trace": r.decision_trace,
|
||||||
"strategy": r.strategy,
|
"strategy": r.strategy,
|
||||||
"entry_signal_type": r.entry_signal_type,
|
"entry_signal_type": r.entry_signal_type,
|
||||||
"scan_session": r.scan_session,
|
"scan_session": r.scan_session,
|
||||||
@ -163,13 +158,12 @@ async def update_tracking(_admin: dict = Depends(get_current_admin)):
|
|||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def get_scan_status():
|
async def get_scan_status():
|
||||||
"""获取当前扫描状态信息"""
|
"""获取当前扫描状态信息。只根据本地时间判断,不访问外部数据源。"""
|
||||||
latest_trade_date = tushare_client.get_latest_trade_date()
|
prefer_realtime = is_trading_hours()
|
||||||
prefer_realtime = should_prefer_realtime_today(latest_trade_date)
|
|
||||||
return {
|
return {
|
||||||
"is_trading": is_trading_hours(),
|
"is_trading": is_trading_hours(),
|
||||||
"scan_mode": "realtime_today" if prefer_realtime else "post_market",
|
"scan_mode": "realtime_today" if prefer_realtime else "post_market",
|
||||||
"description": "今日实时分析优先" if prefer_realtime else "盘后分析(Tushare日级数据)",
|
"description": "交易时段,扫描任务会使用实时源" if prefer_realtime else "非交易时段,展示最近扫描结论",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
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.tushare_client import tushare_client
|
||||||
from app.data.cache import cache
|
from app.data.cache import cache
|
||||||
from app.engine.recommender import get_latest_sectors
|
from app.engine.recommender import get_latest_sectors
|
||||||
@ -13,17 +11,11 @@ router = APIRouter(prefix="/api/sectors", tags=["sectors"])
|
|||||||
|
|
||||||
@router.get("/hot")
|
@router.get("/hot")
|
||||||
async def get_hot_sectors(limit: int = 10):
|
async def get_hot_sectors(limit: int = 10):
|
||||||
"""获取今日主线主题排名(盘中自动补充实时数据并统一归一)"""
|
"""获取最新主线主题排名。
|
||||||
|
|
||||||
|
页面访问只读数据库里的扫描结论,不在 GET 请求中拉取外部实时行情。
|
||||||
|
"""
|
||||||
sectors = await get_latest_sectors()
|
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 ""
|
trade_date = sectors[0].trade_date if sectors else ""
|
||||||
|
|
||||||
sectors_data = [
|
sectors_data = [
|
||||||
@ -57,18 +49,38 @@ async def get_hot_sectors(limit: int = 10):
|
|||||||
"is_realtime": s.is_realtime,
|
"is_realtime": s.is_realtime,
|
||||||
"data_mode": s.data_mode,
|
"data_mode": s.data_mode,
|
||||||
"source": s.source,
|
"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]
|
for s in sectors[:limit]
|
||||||
]
|
]
|
||||||
|
|
||||||
realtime_enabled = any(s.get("is_realtime") for s in sectors_data)
|
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"
|
mode = sectors[0].data_mode if realtime_enabled and sectors else "daily_snapshot"
|
||||||
|
status = _derive_status(sectors_data)
|
||||||
for s in sectors_data:
|
for s in sectors_data:
|
||||||
s["data_mode"] = mode
|
s["data_mode"] = mode
|
||||||
|
s["data_status"] = status
|
||||||
s["structure_trade_date"] = trade_date
|
s["structure_trade_date"] = trade_date
|
||||||
return sectors_data
|
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")
|
@router.get("/rotation")
|
||||||
async def get_sector_rotation(days: int = 5):
|
async def get_sector_rotation(days: int = 5):
|
||||||
"""获取近N日板块轮动数据(用于热力图)"""
|
"""获取近N日板块轮动数据(用于热力图)"""
|
||||||
|
|||||||
@ -109,6 +109,15 @@ async def get_stock_thesis(ts_code: str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return []
|
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 = []
|
tracking_history = []
|
||||||
for row in tracking_rows:
|
for row in tracking_rows:
|
||||||
t = row._mapping
|
t = row._mapping
|
||||||
@ -181,6 +190,7 @@ async def get_stock_thesis(ts_code: str):
|
|||||||
"prefilter_decision": r.get("prefilter_decision") or "",
|
"prefilter_decision": r.get("prefilter_decision") or "",
|
||||||
"prefilter_reason": r.get("prefilter_reason") or "",
|
"prefilter_reason": r.get("prefilter_reason") or "",
|
||||||
"focus_points": _safe_json_list(r.get("focus_points")),
|
"focus_points": _safe_json_list(r.get("focus_points")),
|
||||||
|
"decision_trace": _safe_json_dict(r.get("decision_trace")),
|
||||||
"strategy": r["strategy"] or "trend_breakout",
|
"strategy": r["strategy"] or "trend_breakout",
|
||||||
"entry_signal_type": r["entry_signal_type"] or "none",
|
"entry_signal_type": r["entry_signal_type"] or "none",
|
||||||
"entry_timing": r["entry_timing"] or "",
|
"entry_timing": r["entry_timing"] or "",
|
||||||
|
|||||||
5
backend/app/catalyst/__init__.py
Normal file
5
backend/app/catalyst/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""新闻/政策催化理解层。
|
||||||
|
|
||||||
|
LLM 只在这里做非结构化文本归因,不直接决定买卖。
|
||||||
|
"""
|
||||||
|
|
||||||
262
backend/app/catalyst/mapper.py
Normal file
262
backend/app/catalyst/mapper.py
Normal 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))
|
||||||
44
backend/app/catalyst/models.py
Normal file
44
backend/app/catalyst/models.py
Normal 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"
|
||||||
120
backend/app/catalyst/service.py
Normal file
120
backend/app/catalyst/service.py
Normal 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
|
||||||
@ -8,6 +8,9 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
import httpx
|
import httpx
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -29,6 +32,11 @@ SECTOR_HEADERS = {
|
|||||||
"Referer": "https://data.eastmoney.com",
|
"Referer": "https://data.eastmoney.com",
|
||||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
"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:
|
def _describe_exception(exc: Exception) -> str:
|
||||||
@ -38,6 +46,16 @@ def _describe_exception(exc: Exception) -> str:
|
|||||||
return exc.__class__.__name__
|
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:
|
def _ts_code_to_eastmoney(ts_code: str) -> str:
|
||||||
"""600519.SH -> 1.600519 (上海=1, 深圳=0)"""
|
"""600519.SH -> 1.600519 (上海=1, 深圳=0)"""
|
||||||
code, market = ts_code.split(".")
|
code, market = ts_code.split(".")
|
||||||
@ -84,6 +102,21 @@ async def get_sector_realtime_ranking(
|
|||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
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 = {
|
params = {
|
||||||
"pn": "1",
|
"pn": "1",
|
||||||
"pz": str(page_size),
|
"pz": str(page_size),
|
||||||
@ -99,8 +132,11 @@ async def get_sector_realtime_ranking(
|
|||||||
"fields": "f2,f3,f4,f6,f8,f12,f14,f104,f105,f128,f140,f141",
|
"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"
|
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:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
SECTOR_LIST_URL,
|
SECTOR_LIST_URL,
|
||||||
@ -110,7 +146,24 @@ async def get_sector_realtime_ranking(
|
|||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
data = _parse_eastmoney_json(resp, "板块实时排名")
|
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),
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if last_error and not data:
|
||||||
|
raise last_error
|
||||||
items = data.get("data", {}).get("diff", [])
|
items = data.get("data", {}).get("diff", [])
|
||||||
if not items:
|
if not items:
|
||||||
logger.debug("东方财富板块实时排名无数据")
|
logger.debug("东方财富板块实时排名无数据")
|
||||||
@ -134,21 +187,45 @@ async def get_sector_realtime_ranking(
|
|||||||
"leading_stock_name": item.get("f128", ""),
|
"leading_stock_name": item.get("f128", ""),
|
||||||
"leading_stock_code": item.get("f140", ""),
|
"leading_stock_code": item.get("f140", ""),
|
||||||
"leading_stock_pct": float(item.get("f141", 0) or 0),
|
"leading_stock_pct": float(item.get("f141", 0) or 0),
|
||||||
|
"source": "eastmoney",
|
||||||
|
"data_status": "fresh",
|
||||||
|
"source_detail": "eastmoney_push2",
|
||||||
})
|
})
|
||||||
|
|
||||||
# 缓存:盘中 60 秒,盘后 300 秒
|
# 缓存:盘中 60 秒,盘后 300 秒
|
||||||
ttl = 60 if _is_trading_hours() else 300
|
ttl = 60 if _is_trading_hours() else 300
|
||||||
cache.set(cache_key, result, ttl)
|
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)} 个板块")
|
logger.info(f"东方财富板块实时排名: 获取 {len(result)} 个板块")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"东方财富板块实时排名获取失败: {e}")
|
failure_count = int(failure_state.get("failure_count", 0) or 0) + 1
|
||||||
if notify:
|
_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(
|
await log_error(
|
||||||
"eastmoney",
|
"eastmoney",
|
||||||
f"东方财富板块实时排名获取失败: {e}",
|
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 []
|
return []
|
||||||
|
|
||||||
@ -164,6 +241,31 @@ async def get_a_share_realtime_ranking(
|
|||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
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"
|
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"
|
fields = "f2,f3,f6,f8,f9,f12,f14,f20,f21,f23,f62"
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import asyncio
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@ -25,15 +26,29 @@ logger = logging.getLogger(__name__)
|
|||||||
ZT_POOL_URL = "https://push2ex.eastmoney.com/getTopicZTPool"
|
ZT_POOL_URL = "https://push2ex.eastmoney.com/getTopicZTPool"
|
||||||
DT_POOL_URL = "https://push2ex.eastmoney.com/getTopicDTPool"
|
DT_POOL_URL = "https://push2ex.eastmoney.com/getTopicDTPool"
|
||||||
MIN_RELIABLE_SAMPLE_COUNT = 4500
|
MIN_RELIABLE_SAMPLE_COUNT = 4500
|
||||||
|
_market_breadth_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
|
||||||
async def get_market_breadth() -> MarketBreadth:
|
async def get_market_breadth() -> MarketBreadth:
|
||||||
"""获取市场广度快照。"""
|
"""获取市场广度快照。"""
|
||||||
|
global _market_breadth_task
|
||||||
cache_key = f"market_breadth:{today_trade_date()}"
|
cache_key = f"market_breadth:{today_trade_date()}"
|
||||||
cached = cache.get(cache_key)
|
cached = cache.get(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
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)
|
quotes = await get_a_share_realtime_ranking(page_size=6000)
|
||||||
if quotes and len(quotes) >= MIN_RELIABLE_SAMPLE_COUNT:
|
if quotes and len(quotes) >= MIN_RELIABLE_SAMPLE_COUNT:
|
||||||
up_count = sum(1 for q in quotes if q.get("pct_chg", 0) > 0)
|
up_count = sum(1 for q in quotes if q.get("pct_chg", 0) > 0)
|
||||||
|
|||||||
@ -85,6 +85,11 @@ class SectorInfo(BaseModel):
|
|||||||
is_realtime: bool = False
|
is_realtime: bool = False
|
||||||
data_mode: str = "daily_snapshot"
|
data_mode: str = "daily_snapshot"
|
||||||
source: str = "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):
|
class MarketTemperature(BaseModel):
|
||||||
@ -175,6 +180,7 @@ class Recommendation(BaseModel):
|
|||||||
prefilter_decision: str = ""
|
prefilter_decision: str = ""
|
||||||
prefilter_reason: str = ""
|
prefilter_reason: str = ""
|
||||||
focus_points: list[str] = []
|
focus_points: list[str] = []
|
||||||
|
decision_trace: dict = {}
|
||||||
scan_session: str = ""
|
scan_session: str = ""
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|||||||
@ -59,6 +59,7 @@ async def init_db():
|
|||||||
"ALTER TABLE recommendations ADD COLUMN prefilter_decision TEXT DEFAULT ''",
|
"ALTER TABLE recommendations ADD COLUMN prefilter_decision TEXT DEFAULT ''",
|
||||||
"ALTER TABLE recommendations ADD COLUMN prefilter_reason 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 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 stage TEXT",
|
||||||
"ALTER TABLE sector_heat ADD COLUMN board_type TEXT DEFAULT 'theme'",
|
"ALTER TABLE sector_heat ADD COLUMN board_type TEXT DEFAULT 'theme'",
|
||||||
"ALTER TABLE sector_heat ADD COLUMN theme_id TEXT DEFAULT ''",
|
"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 strategy_configs ADD COLUMN effective_from DATETIME DEFAULT CURRENT_TIMESTAMP",
|
||||||
"ALTER TABLE prompt_configs ADD COLUMN evidence_json TEXT DEFAULT '{}'",
|
"ALTER TABLE prompt_configs ADD COLUMN evidence_json TEXT DEFAULT '{}'",
|
||||||
"ALTER TABLE strategy_config_changes ADD COLUMN prompt_key TEXT DEFAULT ''",
|
"ALTER TABLE strategy_config_changes ADD COLUMN prompt_key TEXT DEFAULT ''",
|
||||||
|
"ALTER TABLE catalysts ADD COLUMN llm_reason TEXT DEFAULT ''",
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
|
|||||||
@ -44,6 +44,7 @@ recommendations_table = Table(
|
|||||||
Column("prefilter_decision", Text, default=""),
|
Column("prefilter_decision", Text, default=""),
|
||||||
Column("prefilter_reason", Text, default=""),
|
Column("prefilter_reason", Text, default=""),
|
||||||
Column("focus_points", Text, default="[]"),
|
Column("focus_points", Text, default="[]"),
|
||||||
|
Column("decision_trace", Text, default="{}"),
|
||||||
Column("scan_session", Text),
|
Column("scan_session", Text),
|
||||||
Column("created_at", DateTime, server_default=func.now()),
|
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("created_at", DateTime, server_default=func.now()),
|
||||||
Column("applied_at", DateTime),
|
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()),
|
||||||
|
)
|
||||||
|
|||||||
@ -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
|
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:
|
async def refresh_recommendations(trade_date: str = None, scan_session: str = "manual") -> dict:
|
||||||
"""刷新推荐列表(带扫描锁防止并发)"""
|
"""刷新推荐列表(带扫描锁防止并发)"""
|
||||||
global _scan_running
|
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_decision": r.get("prefilter_decision") or "",
|
||||||
"prefilter_reason": r.get("prefilter_reason") or "",
|
"prefilter_reason": r.get("prefilter_reason") or "",
|
||||||
"focus_points": json.loads(r.get("focus_points") 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": {
|
"tracking": {
|
||||||
"current_price": r.get("latest_current_price"),
|
"current_price": r.get("latest_current_price"),
|
||||||
"pct_from_entry": r.get("latest_pct_from_entry"),
|
"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_decision": rec.prefilter_decision,
|
||||||
"prefilter_reason": rec.prefilter_reason,
|
"prefilter_reason": rec.prefilter_reason,
|
||||||
"focus_points": json.dumps(rec.focus_points, ensure_ascii=False),
|
"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,
|
"scan_session": rec.scan_session,
|
||||||
"created_at": now_dt,
|
"created_at": now_dt,
|
||||||
}
|
}
|
||||||
@ -916,6 +967,7 @@ async def _load_today_from_db() -> dict:
|
|||||||
prefilter_decision=r.get("prefilter_decision") or "",
|
prefilter_decision=r.get("prefilter_decision") or "",
|
||||||
prefilter_reason=r.get("prefilter_reason") or "",
|
prefilter_reason=r.get("prefilter_reason") or "",
|
||||||
focus_points=json.loads(r.get("focus_points") 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 "",
|
scan_session=r["scan_session"] or "",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@ -40,6 +40,7 @@ from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Reco
|
|||||||
from app.config import settings, should_prefer_realtime_today
|
from app.config import settings, should_prefer_realtime_today
|
||||||
from app.data.tushare_client import tushare_client
|
from app.data.tushare_client import tushare_client
|
||||||
from app.llm.strategy_selector import StrategyProfile, select_strategy_profile
|
from app.llm.strategy_selector import StrategyProfile, select_strategy_profile
|
||||||
|
from app.catalyst.service import build_theme_catalyst_scores
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -96,6 +97,8 @@ async def run_screening(trade_date: str = None) -> dict:
|
|||||||
logger.info("无合格主线主题(需要资金/实时强度+非尾声),回退到全部主题")
|
logger.info("无合格主线主题(需要资金/实时强度+非尾声),回退到全部主题")
|
||||||
hot_sectors = all_themes[:settings.top_sector_count]
|
hot_sectors = all_themes[:settings.top_sector_count]
|
||||||
|
|
||||||
|
hot_sectors = await _apply_catalyst_scores(hot_sectors)
|
||||||
|
|
||||||
for s in hot_sectors:
|
for s in hot_sectors:
|
||||||
logger.info(f" 目标主题: {s.sector_name} 涨幅{s.pct_change}% 资金{s.capital_inflow:.0f}万 "
|
logger.info(f" 目标主题: {s.sector_name} 涨幅{s.pct_change}% 资金{s.capital_inflow:.0f}万 "
|
||||||
f"涨停{s.limit_up_count} 阶段={s.stage}")
|
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(
|
async def _select_from_hot_sectors(
|
||||||
hot_sectors: list[SectorInfo],
|
hot_sectors: list[SectorInfo],
|
||||||
trade_date: str,
|
trade_date: str,
|
||||||
@ -605,10 +640,11 @@ async def _build_recommendations(
|
|||||||
total = len(candidates)
|
total = len(candidates)
|
||||||
signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0}
|
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 {
|
score_weights = strategy_profile.score_weights if strategy_profile else {
|
||||||
"capital_momentum": 0.30,
|
"catalyst": 0.30,
|
||||||
"supply_demand": 0.30,
|
"theme_money": 0.25,
|
||||||
"price_action": 0.25,
|
"stock_money": 0.20,
|
||||||
"trend": 0.15,
|
"emotion_role": 0.15,
|
||||||
|
"timing": 0.10,
|
||||||
}
|
}
|
||||||
score_weights = _normalize_score_weights(score_weights)
|
score_weights = _normalize_score_weights(score_weights)
|
||||||
signal_priority = strategy_profile.entry_signal_priority if strategy_profile else []
|
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_name = signal_type.value
|
||||||
signal_counts[signal_name] += 1
|
signal_counts[signal_name] += 1
|
||||||
|
|
||||||
# ── 三维度评分 ──
|
# ── 五轴评分:催化、主题资金、个股资金、情绪角色、入场时机 ──
|
||||||
supply_demand_score = score_supply_demand(df)
|
supply_demand_score = score_supply_demand(df)
|
||||||
price_action_score = _score_price_action(df, entry_signal)
|
price_action_score = _score_price_action(df, entry_signal)
|
||||||
trend_score = _score_trend(df)
|
trend_score = _score_trend(df)
|
||||||
capital_score = _score_capital_simple(stock)
|
capital_score = _score_capital_simple(stock)
|
||||||
flow_momentum_score = _score_flow_momentum(stock, sector, hot_sectors)
|
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]
|
last = df.iloc[-1]
|
||||||
trend_penalty = 1.0
|
trend_penalty = 1.0
|
||||||
@ -663,15 +719,28 @@ async def _build_recommendations(
|
|||||||
if last["ma5"] < last["ma10"] < last["ma20"]:
|
if last["ma5"] < last["ma10"] < last["ma20"]:
|
||||||
trend_penalty = 0.82
|
trend_penalty = 0.82
|
||||||
|
|
||||||
final_score = (
|
scoring_axes = {
|
||||||
flow_momentum_score * score_weights["capital_momentum"] +
|
"catalyst": catalyst_score,
|
||||||
supply_demand_score * score_weights["supply_demand"] +
|
"theme_money": theme_money_score,
|
||||||
price_action_score * score_weights["price_action"] +
|
"stock_money": stock_money_score,
|
||||||
trend_score * score_weights["trend"]
|
"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
|
final_score *= trend_penalty
|
||||||
|
|
||||||
tech_signal = generate_signals(ts_code, name)
|
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 = []
|
penalties = []
|
||||||
if tech_signal:
|
if tech_signal:
|
||||||
if tech_signal.rally_pct_5d > 20:
|
if tech_signal.rally_pct_5d > 20:
|
||||||
@ -679,9 +748,6 @@ async def _build_recommendations(
|
|||||||
elif tech_signal.rally_pct_5d > 15:
|
elif tech_signal.rally_pct_5d > 15:
|
||||||
penalties.append(0.80)
|
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":
|
if sector_stage == "end":
|
||||||
penalties.append(0.70)
|
penalties.append(0.70)
|
||||||
elif sector_stage == "late":
|
elif sector_stage == "late":
|
||||||
@ -695,31 +761,59 @@ async def _build_recommendations(
|
|||||||
if penalties:
|
if penalties:
|
||||||
final_score *= min(penalties)
|
final_score *= min(penalties)
|
||||||
|
|
||||||
sector_limit_up = _get_sector_limit_up(sector, hot_sectors)
|
boosts = []
|
||||||
if sector_limit_up >= 5:
|
if sector_limit_up >= 5:
|
||||||
final_score *= 1.20
|
final_score *= 1.20
|
||||||
|
boosts.append({"label": "板块涨停扩散", "value": "+20%", "reason": f"{sector_limit_up}家涨停"})
|
||||||
elif sector_limit_up >= 3:
|
elif sector_limit_up >= 3:
|
||||||
final_score *= 1.10
|
final_score *= 1.10
|
||||||
|
boosts.append({"label": "板块涨停扩散", "value": "+10%", "reason": f"{sector_limit_up}家涨停"})
|
||||||
|
|
||||||
if entry_signal.get("signal_score", 0) >= 80:
|
if entry_signal.get("signal_score", 0) >= 80:
|
||||||
final_score *= 1.10
|
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:
|
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]:
|
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])
|
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:
|
if signal_type != EntrySignal.NONE and signal_priority:
|
||||||
priority_rank = signal_priority.index(signal_type.value)
|
priority_rank = signal_priority.index(signal_type.value)
|
||||||
if priority_rank == 0:
|
if priority_rank == 0:
|
||||||
final_score *= 1.08
|
profile_multiplier = 1.08
|
||||||
|
final_score *= profile_multiplier
|
||||||
elif priority_rank == 1:
|
elif priority_rank == 1:
|
||||||
final_score *= 1.04
|
profile_multiplier = 1.04
|
||||||
|
final_score *= profile_multiplier
|
||||||
elif priority_rank >= 3:
|
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")
|
pe = stock.get("pe")
|
||||||
pb = stock.get("pb")
|
pb = stock.get("pb")
|
||||||
@ -833,6 +927,41 @@ async def _build_recommendations(
|
|||||||
entry_timing=entry_timing,
|
entry_timing=entry_timing,
|
||||||
data_date=last_date,
|
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(
|
rec = Recommendation(
|
||||||
ts_code=ts_code,
|
ts_code=ts_code,
|
||||||
@ -868,6 +997,7 @@ async def _build_recommendations(
|
|||||||
prefilter_decision="",
|
prefilter_decision="",
|
||||||
prefilter_reason="",
|
prefilter_reason="",
|
||||||
focus_points=[],
|
focus_points=[],
|
||||||
|
decision_trace=decision_trace,
|
||||||
)
|
)
|
||||||
recommendations.append(rec)
|
recommendations.append(rec)
|
||||||
|
|
||||||
@ -894,7 +1024,7 @@ async def _build_recommendations(
|
|||||||
"entry_signal_score": round(entry_signal.get("signal_score", 0), 1),
|
"entry_signal_score": round(entry_signal.get("signal_score", 0), 1),
|
||||||
"flow_momentum_score": round(flow_momentum_score, 1),
|
"flow_momentum_score": round(flow_momentum_score, 1),
|
||||||
"signal_matches_profile": signal_matches_profile,
|
"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),
|
"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.risk_note = llm_data["risk_flag"]
|
||||||
|
|
||||||
rec.level = _score_to_level(rec.score)
|
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 给出的价格替代结构化规则价格
|
# 用 LLM 给出的价格替代结构化规则价格
|
||||||
if llm_data.get("entry_price"):
|
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]:
|
def _normalize_score_weights(weights: dict[str, float]) -> dict[str, float]:
|
||||||
"""兼容旧策略权重,并保证资金顺势进入主评分。"""
|
"""归一化五轴主评分权重,并兼容旧四项策略配置。"""
|
||||||
defaults = {
|
defaults = {
|
||||||
"capital_momentum": 0.30,
|
"catalyst": 0.30,
|
||||||
"supply_demand": 0.30,
|
"theme_money": 0.25,
|
||||||
"price_action": 0.25,
|
"stock_money": 0.20,
|
||||||
"trend": 0.15,
|
"emotion_role": 0.15,
|
||||||
|
"timing": 0.10,
|
||||||
}
|
}
|
||||||
merged = {**defaults, **(weights or {})}
|
raw = weights or {}
|
||||||
keys = ["capital_momentum", "supply_demand", "price_action", "trend"]
|
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)
|
total = sum(max(float(merged.get(k, 0) or 0), 0) for k in keys)
|
||||||
if total <= 0:
|
if total <= 0:
|
||||||
return defaults
|
return defaults
|
||||||
return {k: max(float(merged.get(k, 0) or 0), 0) / total for k in keys}
|
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:
|
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)
|
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)
|
recall_score = float(stock.get("recall_score", 0) or 0)
|
||||||
sector_heat = _get_sector_heat(sector_name, hot_sectors)
|
sector_heat = _get_sector_heat(sector_name, hot_sectors)
|
||||||
sector_limit_up = _get_sector_limit_up(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
|
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:
|
elif sector_limit_up >= 1:
|
||||||
score += 3
|
score += 3
|
||||||
|
|
||||||
|
if catalyst_score >= 80:
|
||||||
|
score += 8
|
||||||
|
elif catalyst_score >= 60:
|
||||||
|
score += 6
|
||||||
|
elif catalyst_score >= 40:
|
||||||
|
score += 3
|
||||||
|
|
||||||
# 活跃度和召回强度占 35 分。
|
# 活跃度和召回强度占 35 分。
|
||||||
if volume_ratio >= 2.5:
|
if volume_ratio >= 2.5:
|
||||||
score += 12
|
score += 12
|
||||||
@ -1424,6 +1736,20 @@ def _get_sector_limit_up(sector_name: str, hot_sectors: list[SectorInfo]) -> int
|
|||||||
return 0
|
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:
|
def _get_sector_member_count(sector_name: str, hot_sectors: list[SectorInfo]) -> int:
|
||||||
"""获取板块成分股数量"""
|
"""获取板块成分股数量"""
|
||||||
for s in hot_sectors:
|
for s in hot_sectors:
|
||||||
@ -1699,6 +2025,228 @@ def _build_risk_tags(
|
|||||||
return 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(
|
def _build_focus_points(
|
||||||
stock: dict,
|
stock: dict,
|
||||||
entry_signal: dict,
|
entry_signal: dict,
|
||||||
|
|||||||
@ -6,8 +6,6 @@
|
|||||||
|
|
||||||
import logging
|
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.config import settings, should_prefer_realtime_today, today_trade_date
|
||||||
from app.data.models import (
|
from app.data.models import (
|
||||||
MarketTemperature,
|
MarketTemperature,
|
||||||
@ -62,12 +60,6 @@ async def build_strategy_board(include_llm: bool = False) -> dict:
|
|||||||
market_temp = latest.get("market_temp")
|
market_temp = latest.get("market_temp")
|
||||||
recommendations = latest.get("recommendations", [])
|
recommendations = latest.get("recommendations", [])
|
||||||
sectors = await get_latest_sectors()
|
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()
|
performance = await get_performance_stats()
|
||||||
from app.llm.strategy_iteration import build_strategy_iteration_report
|
from app.llm.strategy_iteration import build_strategy_iteration_report
|
||||||
iteration_report = await build_strategy_iteration_report(limit=50, include_llm=include_llm)
|
iteration_report = await build_strategy_iteration_report(limit=50, include_llm=include_llm)
|
||||||
|
|||||||
@ -50,7 +50,7 @@ def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile:
|
|||||||
name="主线突破",
|
name="主线突破",
|
||||||
description="市场偏强,优先寻找主线板块内的突破和突破确认。",
|
description="市场偏强,优先寻找主线板块内的突破和突破确认。",
|
||||||
entry_signal_priority=["breakout", "breakout_confirm", "launch", "pullback", "reversal"],
|
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,
|
min_score=62,
|
||||||
buy_threshold=66,
|
buy_threshold=66,
|
||||||
max_position_pct=30,
|
max_position_pct=30,
|
||||||
@ -67,7 +67,7 @@ def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile:
|
|||||||
name="回踩轮动",
|
name="回踩轮动",
|
||||||
description="市场震荡分化,优先做回踩支撑和板块轮动中的低吸确认。",
|
description="市场震荡分化,优先做回踩支撑和板块轮动中的低吸确认。",
|
||||||
entry_signal_priority=["pullback", "breakout_confirm", "launch", "breakout", "reversal"],
|
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,
|
min_score=60,
|
||||||
buy_threshold=63,
|
buy_threshold=63,
|
||||||
max_position_pct=20,
|
max_position_pct=20,
|
||||||
@ -84,7 +84,7 @@ def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile:
|
|||||||
name="启动试错",
|
name="启动试错",
|
||||||
description="市场偏弱,适合少量观察启动型和反转型机会,不做强追涨。",
|
description="市场偏弱,适合少量观察启动型和反转型机会,不做强追涨。",
|
||||||
entry_signal_priority=["launch", "reversal", "pullback", "breakout_confirm", "breakout"],
|
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,
|
min_score=58,
|
||||||
buy_threshold=61,
|
buy_threshold=61,
|
||||||
max_position_pct=10,
|
max_position_pct=10,
|
||||||
@ -101,7 +101,7 @@ def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile:
|
|||||||
name="防守观察",
|
name="防守观察",
|
||||||
description="市场退潮,系统以观察池为主,不主动扩大出手。",
|
description="市场退潮,系统以观察池为主,不主动扩大出手。",
|
||||||
entry_signal_priority=["pullback", "launch", "reversal", "breakout_confirm", "breakout"],
|
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,
|
min_score=56,
|
||||||
buy_threshold=64,
|
buy_threshold=64,
|
||||||
max_position_pct=5,
|
max_position_pct=5,
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from app.config import settings
|
|||||||
from app.db.error_logger import log_error
|
from app.db.error_logger import log_error
|
||||||
from app.db.database import init_db
|
from app.db.database import init_db
|
||||||
from app.engine.scheduler import start_scheduler, stop_scheduler
|
from app.engine.scheduler import start_scheduler, stop_scheduler
|
||||||
from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, debug
|
from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, debug, catalysts
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -144,6 +144,7 @@ app.include_router(watchlists.router)
|
|||||||
app.include_router(chat.router)
|
app.include_router(chat.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(debug.router)
|
app.include_router(debug.router)
|
||||||
|
app.include_router(catalysts.router)
|
||||||
|
|
||||||
# WebSocket
|
# WebSocket
|
||||||
app.websocket("/ws")(websocket.ws_endpoint)
|
app.websocket("/ws")(websocket.ws_endpoint)
|
||||||
|
|||||||
@ -27,8 +27,8 @@ const CHAT_SCENES = [
|
|||||||
description: "诊断 / 触发 / 失效",
|
description: "诊断 / 触发 / 失效",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "系统",
|
title: "自选",
|
||||||
description: "推荐池 / 自选股 / 校准",
|
description: "推荐池 / 自选股 / 复盘",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -108,9 +108,9 @@ export default function ChatPage() {
|
|||||||
<aside className="hidden xl:flex xl:flex-col xl:gap-4">
|
<aside className="hidden xl:flex xl:flex-col xl:gap-4">
|
||||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-cyan-400">
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-cyan-400">
|
||||||
System Agent
|
Research Desk
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||||
@ -135,9 +135,9 @@ export default function ChatPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<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 className="text-xs text-text-muted">
|
||||||
市场 / 推荐 / 自选 / 个股诊断 / 系统校准
|
市场 / 推荐 / 自选 / 个股诊断
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold">问市场、系统或个股</h3>
|
<h3 className="text-lg font-semibold">问市场、主线或个股</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto mt-8 grid w-full max-w-3xl gap-3 md:grid-cols-2">
|
<div className="mx-auto mt-8 grid w-full max-w-3xl gap-3 md:grid-cols-2">
|
||||||
|
|||||||
@ -54,8 +54,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
if (latest) {
|
if (latest) {
|
||||||
setData(latest);
|
setData(latest);
|
||||||
const realtimeTemp = await fetchAPI<MarketTemperatureData>("/api/market/temperature").catch(() => latest.market_temperature);
|
setMarketTemperature(latest.market_temperature ?? null);
|
||||||
setMarketTemperature(realtimeTemp ?? latest.market_temperature ?? null);
|
|
||||||
}
|
}
|
||||||
setSectors(sectorData);
|
setSectors(sectorData);
|
||||||
if (status) setScanStatus(status);
|
if (status) setScanStatus(status);
|
||||||
@ -69,16 +68,16 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
const loadSecondaryData = useCallback(async () => {
|
const loadSecondaryData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [board, ops] = await Promise.all([
|
const board = await fetchAPI<StrategyBoard>("/api/market/strategy-board").catch(() => null);
|
||||||
fetchAPI<StrategyBoard>("/api/market/strategy-board").catch(() => null),
|
const ops = user?.role === "admin"
|
||||||
fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null),
|
? await fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null)
|
||||||
]);
|
: null;
|
||||||
setStrategyBoard(board);
|
setStrategyBoard(board);
|
||||||
setOpsStatus(ops);
|
setOpsStatus(ops);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("加载次要数据失败:", error);
|
console.error("加载次要数据失败:", error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [user?.role]);
|
||||||
|
|
||||||
const clearScanTimeout = useCallback(() => {
|
const clearScanTimeout = useCallback(() => {
|
||||||
if (scanTimeoutRef.current) {
|
if (scanTimeoutRef.current) {
|
||||||
@ -99,14 +98,14 @@ export default function DashboardPage() {
|
|||||||
useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => {
|
useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => {
|
||||||
clearScanTimeout();
|
clearScanTimeout();
|
||||||
if (msg.type === "scan_update") {
|
if (msg.type === "scan_update") {
|
||||||
const modeLabel = msg.scan_mode === "realtime_today" || msg.scan_mode === "intraday" ? "今日实时" : "历史收盘";
|
const modeLabel = msg.scan_mode === "realtime_today" || msg.scan_mode === "intraday" ? "今日盘中" : "收盘复盘";
|
||||||
setRefreshResult(`${modeLabel}扫描完成,发现 ${msg.count ?? 0} 只股票`);
|
setRefreshResult(`${modeLabel}更新完成,保留 ${msg.count ?? 0} 只股票`);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
loadData();
|
loadData();
|
||||||
loadSecondaryData();
|
loadSecondaryData();
|
||||||
setTimeout(() => setRefreshResult(null), 5000);
|
setTimeout(() => setRefreshResult(null), 5000);
|
||||||
} else if (msg.type === "scan_error") {
|
} else if (msg.type === "scan_error") {
|
||||||
setRefreshResult("扫描失败,请重试");
|
setRefreshResult("更新失败,请重试");
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
setTimeout(() => setRefreshResult(null), 5000);
|
setTimeout(() => setRefreshResult(null), 5000);
|
||||||
} else {
|
} else {
|
||||||
@ -128,21 +127,21 @@ export default function DashboardPage() {
|
|||||||
}>("/api/recommendations/refresh?scan_session=manual");
|
}>("/api/recommendations/refresh?scan_session=manual");
|
||||||
|
|
||||||
if (res.status === "already_running") {
|
if (res.status === "already_running") {
|
||||||
setRefreshResult(res.message || "扫描正在执行中,请稍候");
|
setRefreshResult(res.message || "更新正在执行中,请稍候");
|
||||||
} else if (res.status === "scanning") {
|
} else if (res.status === "scanning") {
|
||||||
setRefreshResult("扫描已启动,完成后自动刷新...");
|
setRefreshResult("更新已启动,完成后自动刷新...");
|
||||||
}
|
}
|
||||||
|
|
||||||
scanTimeoutRef.current = setTimeout(() => {
|
scanTimeoutRef.current = setTimeout(() => {
|
||||||
setRefreshResult("扫描超时,已自动刷新数据");
|
setRefreshResult("更新超时,已自动刷新数据");
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
loadData();
|
loadData();
|
||||||
loadSecondaryData();
|
loadSecondaryData();
|
||||||
setTimeout(() => setRefreshResult(null), 5000);
|
setTimeout(() => setRefreshResult(null), 5000);
|
||||||
}, SCAN_TIMEOUT_MS);
|
}, SCAN_TIMEOUT_MS);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("触发扫描失败:", error);
|
console.error("触发更新失败:", error);
|
||||||
setRefreshResult("触发扫描失败,请重试");
|
setRefreshResult("触发更新失败,请重试");
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
setTimeout(() => setRefreshResult(null), 5000);
|
setTimeout(() => setRefreshResult(null), 5000);
|
||||||
}
|
}
|
||||||
@ -224,7 +223,7 @@ export default function DashboardPage() {
|
|||||||
disabled={refreshing}
|
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"
|
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>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -236,20 +235,13 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div className="animate-fade-in-up">
|
||||||
<DecisionHero
|
<DecisionHero
|
||||||
board={strategyBoard}
|
board={strategyBoard}
|
||||||
summary={marketSummary}
|
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}
|
actions={todayActions}
|
||||||
summary={marketSummary}
|
marketTemperature={marketTemperature ?? data?.market_temperature ?? null}
|
||||||
/>
|
sectors={sectors}
|
||||||
<FocusPanel
|
|
||||||
focusQueue={focusQueue.slice(0, 3)}
|
focusQueue={focusQueue.slice(0, 3)}
|
||||||
actionableCount={actionable.length}
|
actionableCount={actionable.length}
|
||||||
watchCount={watch.length}
|
watchCount={watch.length}
|
||||||
@ -281,101 +273,77 @@ export default function DashboardPage() {
|
|||||||
function DecisionHero({
|
function DecisionHero({
|
||||||
board,
|
board,
|
||||||
summary,
|
summary,
|
||||||
|
actions,
|
||||||
|
marketTemperature,
|
||||||
|
sectors,
|
||||||
|
focusQueue,
|
||||||
actionableCount,
|
actionableCount,
|
||||||
watchCount,
|
watchCount,
|
||||||
observeCount,
|
observeCount,
|
||||||
}: {
|
}: {
|
||||||
board: StrategyBoard | null;
|
board: StrategyBoard | null;
|
||||||
summary: ReturnType<typeof buildMarketSummary>;
|
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>;
|
actions: ReturnType<typeof buildActionGuides>;
|
||||||
summary: ReturnType<typeof buildMarketSummary>;
|
marketTemperature: MarketTemperatureData | null;
|
||||||
}) {
|
sectors: SectorData[];
|
||||||
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,
|
|
||||||
}: {
|
|
||||||
focusQueue: RecommendationData[];
|
focusQueue: RecommendationData[];
|
||||||
actionableCount: number;
|
actionableCount: number;
|
||||||
watchCount: number;
|
watchCount: number;
|
||||||
observeCount: number;
|
observeCount: number;
|
||||||
}) {
|
}) {
|
||||||
|
const leadingSectors = sectors.slice(0, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card-static p-4 md:p-5">
|
<div className="glass-card-static overflow-hidden">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||||||
<div>
|
<div className="p-5 md:p-6">
|
||||||
<h3 className="text-sm font-semibold text-text-primary">焦点标的</h3>
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
</div>
|
<span className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">今日作战台</span>
|
||||||
<div className="grid grid-cols-3 gap-2 text-center">
|
<div className="flex flex-wrap gap-2">
|
||||||
<MiniCount label="可操作" value={actionableCount} tone="text-red-400" />
|
<CompactBadge label="打法" value={summary.modeLabel} />
|
||||||
<MiniCount label="关注" value={watchCount} tone="text-amber-400" />
|
<CompactBadge label="仓位" value={summary.positionLabel} />
|
||||||
<MiniCount label="观察" value={observeCount} tone="text-text-secondary" />
|
<CompactBadge label="风险" value={board?.risk_level ?? "等待更新"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
<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.length ? (
|
||||||
focusQueue.map((rec) => <FocusStockCard key={rec.ts_code} rec={rec} />)
|
focusQueue.map((rec) => <FocusStockCard key={rec.ts_code} rec={rec} />)
|
||||||
) : (
|
) : (
|
||||||
@ -385,6 +353,9 @@ function FocusPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -406,7 +377,7 @@ function MarketSnapshot({
|
|||||||
<div className="glass-card-static p-4 md:p-5">
|
<div className="glass-card-static p-4 md:p-5">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-text-primary">市场证据</h3>
|
<h3 className="text-sm font-semibold text-text-primary">盘面依据</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-xl bg-surface-1/70 px-3 py-1.5 text-xs font-mono tabular-nums text-text-secondary">
|
<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)}
|
温度 {Math.round(marketTemperature?.temperature ?? 0)}
|
||||||
@ -527,7 +498,7 @@ function AdminPanel({
|
|||||||
disabled={refreshing || !!opsRunning}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => onAction("update_tracking")}
|
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 }) {
|
function HeroMetric({ label, value, tone }: { label: string; value: number; tone: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3 text-center">
|
<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 (
|
return (
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3">
|
<div className={`rounded-2xl border p-3 ${toneClass}`}>
|
||||||
<div className="text-[10px] uppercase tracking-wider text-text-muted">{label}</div>
|
<div className="text-[11px] font-semibold">{title}</div>
|
||||||
<div className="mt-1 text-xs font-semibold leading-5 text-text-secondary line-clamp-2">{value}</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -594,81 +552,69 @@ function CompactBadge({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionBucket({
|
function SectorChip({ sector }: { sector: SectorData }) {
|
||||||
title,
|
const pct = sector.realtime_pct_change ?? sector.pct_change;
|
||||||
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";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
<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">
|
||||||
<div className={`inline-flex rounded-lg px-2 py-1 text-[10px] font-semibold uppercase tracking-wider ${toneClass}`}>
|
<span className="max-w-[8rem] truncate text-text-secondary">{sector.sector_name}</span>
|
||||||
{title}
|
<span className={`font-mono tabular-nums ${pct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||||
</div>
|
{pct >= 0 ? "+" : ""}{pct.toFixed(2)}%
|
||||||
<div className="mt-3 space-y-2">
|
</span>
|
||||||
{items.map((item, index) => (
|
</span>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FocusStockCard({ rec }: { rec: RecommendationData }) {
|
function FocusStockCard({ rec }: { rec: RecommendationData }) {
|
||||||
const badgeClass =
|
const stripeClass =
|
||||||
rec.action_plan === "可操作"
|
rec.action_plan === "可操作"
|
||||||
? "border-red-500/20 bg-red-500/10 text-red-400"
|
? "bg-red-400"
|
||||||
: rec.action_plan === "重点关注"
|
: rec.action_plan === "重点关注"
|
||||||
? "border-amber-500/20 bg-amber-500/10 text-amber-400"
|
? "bg-amber-400"
|
||||||
: "border-border-subtle bg-surface-2 text-text-muted";
|
: "bg-text-muted";
|
||||||
|
const labelClass =
|
||||||
|
rec.action_plan === "可操作"
|
||||||
|
? "text-red-400"
|
||||||
|
: rec.action_plan === "重点关注"
|
||||||
|
? "text-amber-400"
|
||||||
|
: "text-text-muted";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={`/stock/${rec.ts_code}`}
|
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">
|
<span className={`mt-1 h-[calc(100%-0.5rem)] rounded-full ${stripeClass}`} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-sm font-semibold text-text-primary">{rec.name}</span>
|
<div className="min-w-0 flex items-center gap-2">
|
||||||
<span className={`rounded-lg border px-2 py-0.5 text-[10px] ${badgeClass}`}>{rec.action_plan ?? "观察"}</span>
|
<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>
|
||||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5 text-[11px] text-text-muted">
|
{rec.suggested_position_pct != null ? (
|
||||||
<span className="font-mono tabular-nums">{rec.ts_code}</span>
|
<span className="shrink-0 font-mono text-[11px] tabular-nums text-text-muted">{rec.suggested_position_pct}%</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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] text-text-muted">
|
||||||
<div className="text-xs font-mono tabular-nums text-text-secondary">{Math.round(rec.score)}</div>
|
<span className="font-mono tabular-nums">{rec.ts_code}</span>
|
||||||
<div className="mt-0.5 text-[10px] text-text-muted">参考分</div>
|
{rec.sector ? (
|
||||||
</div>
|
<>
|
||||||
|
<span className="text-text-muted/50">/</span>
|
||||||
|
<span className="truncate text-text-secondary">{rec.sector}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 text-sm leading-6 text-text-secondary">
|
<div className="mt-1.5 text-xs leading-5 text-text-secondary line-clamp-2">
|
||||||
{rec.trigger_condition ?? rec.entry_timing ?? rec.prefilter_reason ?? rec.reasons?.[0] ?? "等待新的触发条件。"}
|
{rec.decision_trace?.headline ?? rec.trigger_condition ?? rec.entry_timing ?? rec.prefilter_reason ?? rec.reasons?.[0] ?? "等待新的触发条件。"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(rec.invalidation_condition || rec.risk_note) ? (
|
{(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">
|
<div className="mt-1 text-[11px] leading-5 text-text-muted line-clamp-1">
|
||||||
风险: {rec.invalidation_condition ?? rec.risk_note}
|
失效: {rec.invalidation_condition ?? rec.risk_note}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</a>
|
</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 }) {
|
function FreshnessPill({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-surface-1/70 px-2.5 py-2">
|
<div className="rounded-xl bg-surface-1/70 px-2.5 py-2">
|
||||||
@ -722,8 +659,8 @@ function buildMarketSummary(
|
|||||||
strategyProfile?.decision_note ??
|
strategyProfile?.decision_note ??
|
||||||
board?.summary ??
|
board?.summary ??
|
||||||
(scanStatus?.is_trading
|
(scanStatus?.is_trading
|
||||||
? "盘中实时:看节奏、仓位、主线强弱。"
|
? "当前结论:看节奏、仓位、主线强弱。"
|
||||||
: "收盘快照:复盘主线,准备下一交易日。");
|
: "最近结论:复盘主线,准备下一交易日。");
|
||||||
|
|
||||||
const canDo = [
|
const canDo = [
|
||||||
!allowTrading
|
!allowTrading
|
||||||
@ -748,7 +685,6 @@ function buildMarketSummary(
|
|||||||
cannotDo,
|
cannotDo,
|
||||||
modeLabel: strategyProfile?.market_stance || board?.recommended_mode || "等待更新",
|
modeLabel: strategyProfile?.market_stance || board?.recommended_mode || "等待更新",
|
||||||
positionLabel: board?.position_suggestion || (strategyProfile?.max_position_pct ? `${strategyProfile.max_position_pct}% 以内` : "等待更新"),
|
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]
|
||||||
? `${watch[1].name}:观察队列。`
|
? `${watch[1].name}:观察队列。`
|
||||||
: "新热点先看板块扩散。",
|
: "新热点先看板块扩散。",
|
||||||
observe.length > 0 ? `后台观察 ${observe.length} 只。` : "暂无弱候选。",
|
observe.length > 0 ? `观察池 ${observe.length} 只。` : "暂无弱候选。",
|
||||||
];
|
];
|
||||||
|
|
||||||
const avoid = [
|
const avoid = [
|
||||||
|
|||||||
@ -191,7 +191,7 @@ export default function DiagnosePage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold tracking-tight">AI 诊断</h1>
|
<h1 className="text-lg font-bold tracking-tight">个股诊断</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -278,9 +278,9 @@ export default function DiagnosePage() {
|
|||||||
|
|
||||||
{thesis ? (
|
{thesis ? (
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
<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">
|
<div className="text-sm text-text-secondary mt-2 leading-relaxed">
|
||||||
{thesis.data_freshness.message}
|
已读取该股最近推荐、跟踪和诊断记录。
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-2 mt-3">
|
<div className="grid grid-cols-1 gap-2 mt-3">
|
||||||
{(thesis.decision_points ?? []).slice(0, 3).map((point) => (
|
{(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="glass-card-static p-5 animate-fade-in-up">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<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="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>
|
||||||
<div className="text-sm text-text-secondary leading-relaxed whitespace-pre-line">
|
<div className="text-sm text-text-secondary leading-relaxed whitespace-pre-line">
|
||||||
{displayContent}
|
{displayContent}
|
||||||
@ -349,8 +349,8 @@ export default function DiagnosePage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
<DiagnosisSummaryCard label="执行建议" value={buildDiagnosisAction(thesis, diagnoseMode)} />
|
<DiagnosisSummaryCard label="执行建议" value={buildDiagnosisAction(thesis, diagnoseMode)} />
|
||||||
<DiagnosisSummaryCard label="下一步" value={buildDiagnosisNextStep(thesis, diagnoseMode)} />
|
<DiagnosisSummaryCard label="下一步" value={buildDiagnosisNextStep(thesis, diagnoseMode)} />
|
||||||
<DiagnosisSummaryCard label="触发关注" value={thesis?.recommendation?.trigger_condition || "等待 AI 在长文中补充"} />
|
<DiagnosisSummaryCard label="触发关注" value={thesis?.recommendation?.trigger_condition || "等待正文补充"} />
|
||||||
<DiagnosisSummaryCard label="失效边界" value={thesis?.recommendation?.invalidation_condition || "等待 AI 在长文中补充"} />
|
<DiagnosisSummaryCard label="失效边界" value={thesis?.recommendation?.invalidation_condition || "等待正文补充"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="glass-card-static p-5">
|
<div className="glass-card-static p-5">
|
||||||
@ -358,7 +358,7 @@ export default function DiagnosePage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-text-primary">{result?.ts_code || codeParam}</span>
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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">
|
<div className="text-xs text-text-muted/50 mb-4">
|
||||||
支持股票代码(如 600683)或名称(如 京投发展)
|
支持股票代码(如 600683)或名称(如 京投发展)
|
||||||
</div>
|
</div>
|
||||||
@ -494,7 +494,7 @@ function buildDiagnosisConclusion(thesis: StockThesisResponse | null, loading: b
|
|||||||
return thesis.recommendation.action_plan;
|
return thesis.recommendation.action_plan;
|
||||||
}
|
}
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return "AI 正在生成会诊";
|
return "正在生成会诊";
|
||||||
}
|
}
|
||||||
if (thesis?.has_recommendation === false) {
|
if (thesis?.has_recommendation === false) {
|
||||||
return "暂无推荐归档,以本次诊断为准";
|
return "暂无推荐归档,以本次诊断为准";
|
||||||
@ -510,7 +510,7 @@ function buildDiagnosisRisk(thesis: StockThesisResponse | null, loading: boolean
|
|||||||
return thesis.latest_tracking.review_note;
|
return thesis.latest_tracking.review_note;
|
||||||
}
|
}
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return "AI 正在补充风险判断";
|
return "正在补充风险判断";
|
||||||
}
|
}
|
||||||
return "当前没有明确风险备注";
|
return "当前没有明确风险备注";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,10 +13,10 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
|
|||||||
<div className="px-6 pt-7 pb-5">
|
<div className="px-6 pt-7 pb-5">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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>
|
||||||
<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>
|
<p className="text-xs text-text-muted mt-0.5 font-light tracking-wide">A 股投研作战台</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import type {
|
|||||||
RecommendationData,
|
RecommendationData,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import StockCard from "@/components/stock-card";
|
import StockCard from "@/components/stock-card";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
@ -25,7 +26,7 @@ function formatDate(dateStr: string): string {
|
|||||||
|
|
||||||
type RecommendationWithDate = RecommendationData & { groupDate: string };
|
type RecommendationWithDate = RecommendationData & { groupDate: string };
|
||||||
|
|
||||||
type FocusTab = "actionable" | "watch" | "tracking" | "closed";
|
type FocusTab = "actionable" | "watch" | "observe" | "tracking" | "closed";
|
||||||
|
|
||||||
const SIGNAL_FILTERS = [
|
const SIGNAL_FILTERS = [
|
||||||
{ key: "all", label: "全部" },
|
{ key: "all", label: "全部" },
|
||||||
@ -36,6 +37,7 @@ const SIGNAL_FILTERS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function RecommendationsPage() {
|
export default function RecommendationsPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
const [dayGroups, setDayGroups] = useState<DayGroup[]>([]);
|
const [dayGroups, setDayGroups] = useState<DayGroup[]>([]);
|
||||||
const [latest, setLatest] = useState<LatestResult | null>(null);
|
const [latest, setLatest] = useState<LatestResult | null>(null);
|
||||||
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
|
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
|
||||||
@ -48,7 +50,7 @@ export default function RecommendationsPage() {
|
|||||||
const [history, latestResult, ops] = await Promise.all([
|
const [history, latestResult, ops] = await Promise.all([
|
||||||
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
||||||
fetchAPI<LatestResult>("/api/recommendations/latest").catch(() => null),
|
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);
|
setDayGroups(history);
|
||||||
@ -62,7 +64,7 @@ export default function RecommendationsPage() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("加载推荐失败:", error);
|
console.error("加载推荐失败:", error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [user?.role]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
@ -103,6 +105,7 @@ export default function RecommendationsPage() {
|
|||||||
const focusTabs: Array<{ key: FocusTab; label: string; count: number; description: string }> = [
|
const focusTabs: Array<{ key: FocusTab; label: string; count: number; description: string }> = [
|
||||||
{ key: "actionable", label: "可操作", count: actionable.length, description: "执行名单" },
|
{ key: "actionable", label: "可操作", count: actionable.length, description: "执行名单" },
|
||||||
{ key: "watch", label: "重点关注", count: watch.length, description: "等待确认" },
|
{ key: "watch", label: "重点关注", count: watch.length, description: "等待确认" },
|
||||||
|
{ key: "observe", label: "观察池", count: observe.length, description: "不急处理" },
|
||||||
{ key: "tracking", label: "跟踪中", count: tracking.length, description: "兑现进度" },
|
{ key: "tracking", label: "跟踪中", count: tracking.length, description: "兑现进度" },
|
||||||
{ key: "closed", label: "已结束", count: closed.length, description: "复盘样本" },
|
{ key: "closed", label: "已结束", count: closed.length, description: "复盘样本" },
|
||||||
];
|
];
|
||||||
@ -110,9 +113,10 @@ export default function RecommendationsPage() {
|
|||||||
const focusItems = useMemo(() => {
|
const focusItems = useMemo(() => {
|
||||||
if (focusTab === "actionable") return actionable;
|
if (focusTab === "actionable") return actionable;
|
||||||
if (focusTab === "watch") return watch;
|
if (focusTab === "watch") return watch;
|
||||||
|
if (focusTab === "observe") return observe.slice(0, 12);
|
||||||
if (focusTab === "tracking") return tracking.slice(0, 8);
|
if (focusTab === "tracking") return tracking.slice(0, 8);
|
||||||
return closed.slice(0, 8);
|
return closed.slice(0, 8);
|
||||||
}, [actionable, closed, focusTab, tracking, watch]);
|
}, [actionable, closed, focusTab, observe, tracking, watch]);
|
||||||
|
|
||||||
const themeFocus = useMemo(() => {
|
const themeFocus = useMemo(() => {
|
||||||
const source = latestRecommendations.filter((rec) => ["可操作", "重点关注"].includes(rec.action_plan ?? ""));
|
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>
|
<h1 className="text-base sm:text-lg font-bold tracking-tight">今日决策池</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
<div className="glass-card-static overflow-hidden animate-fade-in-up">
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4">
|
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||||
<div>
|
<div className="p-4 md:p-5">
|
||||||
<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>
|
||||||
<h2 className="mt-2 text-xl font-bold tracking-tight text-text-primary">{focusSummary.headline}</h2>
|
<h2 className="mt-2 text-2xl md:text-3xl 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>
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary">{focusSummary.detail}</p>
|
||||||
|
|
||||||
{latest?.strategy_profile ? (
|
{latest?.strategy_profile ? (
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<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.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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -179,18 +183,22 @@ export default function RecommendationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 self-start">
|
<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={latestRecommendations.length} tone="text-text-primary" />
|
||||||
<SummaryMetric label="可操作" value={actionable.length} tone="text-red-400" />
|
<SummaryMetric label="可操作" value={actionable.length} tone="text-red-400" />
|
||||||
<SummaryMetric label="重点关注" value={watch.length} tone="text-amber-400" />
|
<SummaryMetric label="重点关注" value={watch.length} tone="text-amber-400" />
|
||||||
<SummaryMetric label="跟踪中" value={tracking.length} tone="text-cyan-400" />
|
<SummaryMetric label="观察池" value={observe.length} tone="text-text-secondary" />
|
||||||
<SummaryFact label="历史累计" value={`${totalCount} 只`} />
|
</div>
|
||||||
<SummaryFact label="已结束样本" value={`${closed.length} 只`} />
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<SummaryFact label="历史记录" value={`${totalCount} 只`} />
|
||||||
|
<SummaryFact label="已复盘" value={`${closed.length} 只`} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{opsStatus ? (
|
{user?.role === "admin" && opsStatus ? (
|
||||||
<div className="glass-card-static p-3.5 animate-fade-in-up">
|
<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="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||||
<div className="text-xs text-text-secondary">
|
<div className="text-xs text-text-secondary">
|
||||||
@ -206,43 +214,37 @@ export default function RecommendationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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-4 md:p-5 animate-fade-in-up">
|
||||||
<div className="glass-card-static p-3">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-text-muted font-semibold mb-3">焦点分组</div>
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="space-y-2">
|
<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) => (
|
{focusTabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setFocusTab(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
|
focusTab === tab.key
|
||||||
? "border-amber-500/20 bg-amber-500/[0.06]"
|
? "border-amber-500/20 bg-amber-500/[0.06]"
|
||||||
: "border-border-subtle bg-surface-1 hover:bg-surface-2"
|
: "border-border-subtle bg-surface-1 hover:bg-surface-2"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-0">
|
<span className="text-xs font-semibold text-text-primary">{tab.label}</span>
|
||||||
<div className="text-sm font-semibold text-text-primary">{tab.label}</div>
|
<span className="font-mono text-xs tabular-nums text-text-muted">{tab.count}</span>
|
||||||
<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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
|
|
||||||
{focusItems.length ? (
|
{focusItems.length ? (
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{focusItems.map((rec) => (
|
{focusItems.map((rec) => (
|
||||||
@ -257,33 +259,6 @@ export default function RecommendationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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="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 className="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@ -410,7 +385,7 @@ function buildFocusSummary({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const later = [
|
const later = [
|
||||||
observe.length > 0 ? `${observe.length} 只后台观察。` : "不堆弱标的。",
|
observe.length > 0 ? `${observe.length} 只观察池标的。` : "不堆弱标的。",
|
||||||
closed.length > 0 ? `${closed.length} 只已结束样本。` : "暂无结束样本。",
|
closed.length > 0 ? `${closed.length} 只已结束样本。` : "暂无结束样本。",
|
||||||
"不追无触发标的。",
|
"不追无触发标的。",
|
||||||
];
|
];
|
||||||
|
|||||||
@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { fetchAPI } from "@/lib/api";
|
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 { formatNumber } from "@/lib/utils";
|
||||||
import { ErrorBoundary } from "@/components/error-boundary";
|
import { ErrorBoundary } from "@/components/error-boundary";
|
||||||
import { useWebSocket } from "@/hooks/use-websocket";
|
import { useWebSocket } from "@/hooks/use-websocket";
|
||||||
|
|
||||||
function getThemeAliasLine(sector: SectorData) {
|
function getThemeAliasLine(sector: SectorData) {
|
||||||
const aliases = (sector.theme_aliases ?? []).filter((alias) => alias && alias !== sector.sector_name).slice(0, 4);
|
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(" / ")}`;
|
return `包含:${aliases.join(" / ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,6 +70,14 @@ function getLeaders(sector: SectorData): LeadingStock[] {
|
|||||||
: sector.leading_stocks ?? [];
|
: 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[]) {
|
function getHeadline(sectors: SectorData[]) {
|
||||||
const primary = sectors[0];
|
const primary = sectors[0];
|
||||||
const secondary = sectors[1];
|
const secondary = sectors[1];
|
||||||
@ -78,8 +86,8 @@ function getHeadline(sectors: SectorData[]) {
|
|||||||
if (!primary) {
|
if (!primary) {
|
||||||
return {
|
return {
|
||||||
title: "暂无主线数据",
|
title: "暂无主线数据",
|
||||||
detail: "等待实时主题榜或扫描结果更新后再判断。",
|
detail: "等待新的主线结论后再判断。",
|
||||||
canDo: ["等待主题榜更新。"],
|
canDo: ["等待主线结论更新。"],
|
||||||
avoid: ["不做情绪追涨。"],
|
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 }) {
|
function LeadingStockPill({ stock }: { stock: LeadingStock }) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@ -209,7 +199,7 @@ function LaneCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 space-y-2.5">
|
<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 className="text-xs text-text-muted">暂无数据</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -217,26 +207,36 @@ 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 displayPct = sector.realtime_pct_change ?? sector.pct_change;
|
||||||
const stage = getStageInfo(sector.stage ?? "");
|
const stage = getStageInfo(sector.stage ?? "");
|
||||||
const action = getActionPlan(sector);
|
const action = getActionPlan(sector);
|
||||||
const leaders = getLeaders(sector).slice(0, 2);
|
const leaders = getLeaders(sector).slice(0, 2);
|
||||||
|
const catalyst = getCatalystLabel(sector);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border-subtle bg-surface-2/70 px-3 py-3">
|
<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="flex items-start justify-between gap-3">
|
||||||
|
<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="min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-sm font-semibold text-text-primary">{sector.sector_name}</span>
|
<span className="text-sm font-semibold text-text-primary">{sector.sector_name}</span>
|
||||||
{sector.board_type === "theme" ? (
|
{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 className="rounded-md border border-sky-500/15 bg-sky-500/10 px-1.5 py-0.5 text-[10px] text-sky-300">
|
||||||
系统主题
|
主题
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${stage.bg} ${stage.color}`}>
|
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${stage.bg} ${stage.color}`}>
|
||||||
{stage.label}
|
{stage.label}
|
||||||
</span>
|
</span>
|
||||||
|
{catalyst ? (
|
||||||
|
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${catalyst.className}`}>
|
||||||
|
{catalyst.label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[11px] text-text-muted">
|
<div className="mt-1 text-[11px] text-text-muted">
|
||||||
{getThemeAliasLine(sector)}
|
{getThemeAliasLine(sector)}
|
||||||
@ -245,6 +245,7 @@ function LaneRow({ sector }: { sector: SectorData }) {
|
|||||||
代表股:{leaders.length ? leaders.map((item) => item.name).join(" / ") : "暂无"}
|
代表股:{leaders.length ? leaders.map((item) => item.name).join(" / ") : "暂无"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="text-right shrink-0">
|
<div className="text-right shrink-0">
|
||||||
<div className={`text-sm font-bold font-mono tabular-nums ${displayPct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
<div className={`text-sm font-bold font-mono tabular-nums ${displayPct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||||
{displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
|
{displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
|
||||||
@ -268,39 +269,35 @@ function SectorCard({ sector, index }: { sector: SectorData; index: number }) {
|
|||||||
const displayPct = sector.realtime_pct_change ?? sector.pct_change;
|
const displayPct = sector.realtime_pct_change ?? sector.pct_change;
|
||||||
const displayAmount = sector.realtime_amount ?? sector.capital_inflow;
|
const displayAmount = sector.realtime_amount ?? sector.capital_inflow;
|
||||||
const displayLimitUp = sector.realtime_limit_up_count ?? sector.limit_up_count;
|
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 stage = getStageInfo(sector.stage ?? "");
|
||||||
const leaders = getLeaders(sector).slice(0, 3);
|
const leaders = getLeaders(sector).slice(0, 3);
|
||||||
const action = getActionPlan(sector);
|
const action = getActionPlan(sector);
|
||||||
|
const catalyst = getCatalystLabel(sector);
|
||||||
|
const catalystReason = sector.catalyst_reasons?.[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card animate-fade-in-up overflow-hidden" style={{ animationDelay: `${index * 40}ms` }}>
|
<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="p-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-sm font-semibold text-text-primary">{sector.sector_name}</span>
|
<span className="text-sm font-semibold text-text-primary">{sector.sector_name}</span>
|
||||||
{sector.board_type === "theme" ? (
|
{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 className="rounded-md border border-sky-500/15 bg-sky-500/10 px-1.5 py-0.5 text-[10px] text-sky-300">
|
||||||
系统主题
|
主题
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${stage.bg} ${stage.color}`}>
|
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${stage.bg} ${stage.color}`}>
|
||||||
{stage.label}
|
{stage.label}
|
||||||
</span>
|
</span>
|
||||||
{sector.is_realtime ? (
|
{catalyst ? (
|
||||||
<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">
|
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${catalyst.className}`}>
|
||||||
实时
|
{catalyst.label}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[11px] text-text-muted">
|
<div className="mt-1 text-[11px] text-text-muted">
|
||||||
{action.label} · {action.description}
|
{action.label} · {action.description}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[11px] text-text-muted">
|
|
||||||
{getThemeAliasLine(sector)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right shrink-0">
|
<div className="text-right shrink-0">
|
||||||
<div className={`text-base font-bold font-mono tabular-nums ${displayPct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-4 gap-2">
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
<MetricBox label={sector.is_realtime ? "成交额" : "资金"}>
|
<MetricBox label="资金">
|
||||||
<span className={`font-mono tabular-nums ${displayAmount >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
<span className={`font-mono tabular-nums ${displayAmount >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||||
{displayAmount >= 0 ? "+" : ""}{formatNumber(displayAmount)}
|
{displayAmount >= 0 ? "+" : ""}{formatNumber(displayAmount)}
|
||||||
</span>
|
</span>
|
||||||
</MetricBox>
|
</MetricBox>
|
||||||
<MetricBox label={sector.is_realtime ? "上涨/下跌" : "涨停"}>
|
<MetricBox label="涨停">
|
||||||
{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>
|
<span className="font-mono tabular-nums text-text-secondary">{displayLimitUp}只</span>
|
||||||
)}
|
|
||||||
</MetricBox>
|
</MetricBox>
|
||||||
<MetricBox label={sector.is_realtime ? "换手" : "连板"}>
|
<MetricBox label="主力">
|
||||||
<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="主力占比">
|
|
||||||
<span className="font-mono tabular-nums text-text-secondary">{(sector.main_force_ratio ?? 0).toFixed(1)}%</span>
|
<span className="font-mono tabular-nums text-text-secondary">{(sector.main_force_ratio ?? 0).toFixed(1)}%</span>
|
||||||
</MetricBox>
|
</MetricBox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-2">
|
{catalystReason ? (
|
||||||
<div className="text-[10px] uppercase tracking-wider text-text-muted font-semibold">跟踪重点</div>
|
<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">
|
<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}
|
{action.risk}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -349,7 +346,6 @@ function SectorCard({ sector, index }: { sector: SectorData; index: number }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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() {
|
export default function SectorsPage() {
|
||||||
const [sectors, setSectors] = useState<SectorData[]>([]);
|
const [sectors, setSectors] = useState<SectorData[]>([]);
|
||||||
const [showRotation, setShowRotation] = useState(false);
|
|
||||||
const [rotationData, setRotationData] = useState<SectorRotationData | null>(null);
|
|
||||||
const [stageFilter, setStageFilter] = useState<string>("all");
|
const [stageFilter, setStageFilter] = useState<string>("all");
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
@ -481,29 +381,9 @@ export default function SectorsPage() {
|
|||||||
}, [loadData])
|
}, [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 summary = getHeadline(sectors);
|
||||||
const topPct = sectors[0] ? (sectors[0].realtime_pct_change ?? sectors[0].pct_change) : 0;
|
const topPct = sectors[0] ? (sectors[0].realtime_pct_change ?? sectors[0].pct_change) : 0;
|
||||||
const hasPositiveLeader = topPct > 0;
|
const hasPositiveLeader = topPct > 0;
|
||||||
const sourceRiskHint = getSourceRiskHint(source, dataMode);
|
|
||||||
|
|
||||||
const stageCounts = useMemo(() => {
|
const stageCounts = useMemo(() => {
|
||||||
const counts = { all: sectors.length, early: 0, mid: 0, late_end: 0 };
|
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="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">
|
<div className="animate-fade-in-up">
|
||||||
<h1 className="text-lg font-bold tracking-tight">主线主题</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
{!sectors.length ? (
|
{!sectors.length ? (
|
||||||
<div className="glass-card-static p-12 text-center animate-fade-in-up">
|
<div className="glass-card-static p-12 text-center animate-fade-in-up">
|
||||||
<div className="text-sm text-text-muted">暂无板块数据</div>
|
<div className="text-sm text-text-muted">暂无主线结论</div>
|
||||||
<div className="mt-1 text-xs text-text-muted/50">触发扫描后自动更新</div>
|
<div className="mt-1 text-xs text-text-muted/50">等下一次复盘后再看。</div>
|
||||||
</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">{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">{secondary.length}</span></MetricBox>
|
||||||
<MetricBox label="观察线"><span className="font-mono tabular-nums text-text-primary">{watchline.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>
|
||||||
</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
|
<LaneCard
|
||||||
title={hasPositiveLeader ? "今日主线" : "相对抗跌"}
|
title={hasPositiveLeader ? "今日主线" : "相对抗跌"}
|
||||||
description={hasPositiveLeader ? "优先方向" : "暂不进攻"}
|
description={hasPositiveLeader ? "优先方向" : "暂不进攻"}
|
||||||
@ -599,7 +465,7 @@ export default function SectorsPage() {
|
|||||||
<div className="glass-card-static p-4 md:p-5">
|
<div className="glass-card-static p-4 md:p-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-text-primary">完整方向列表</h2>
|
<h2 className="text-sm font-semibold text-text-primary">方向清单</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{[
|
{[
|
||||||
@ -629,7 +495,7 @@ export default function SectorsPage() {
|
|||||||
当前筛选下无板块数据。
|
当前筛选下无板块数据。
|
||||||
</div>
|
</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) => (
|
{filteredSectors.map((sector, index) => (
|
||||||
<SectorCard key={sector.sector_code} sector={sector} index={index} />
|
<SectorCard key={sector.sector_code} sector={sector} index={index} />
|
||||||
))}
|
))}
|
||||||
@ -637,35 +503,13 @@ export default function SectorsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="glass-card-static p-4 self-start">
|
||||||
<div className="glass-card-static p-4">
|
<h2 className="text-sm font-semibold text-text-primary">怎么使用</h2>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="mt-3 space-y-2 text-sm leading-6 text-text-secondary">
|
||||||
<div>
|
<div>先看今日主线,再看前排代表股。</div>
|
||||||
<h2 className="text-sm font-semibold text-text-primary">方向轮动</h2>
|
<div>只在回流、扩散和承接同时出现时提高优先级。</div>
|
||||||
|
<div>后期和尾声方向只做观察,不当作新主线追。</div>
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
{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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -674,9 +518,3 @@ export default function SectorsPage() {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useNextTheme() {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const { useTheme } = require("next-themes");
|
|
||||||
return useTheme();
|
|
||||||
}
|
|
||||||
|
|||||||
@ -162,7 +162,7 @@ export default function StockDetailPage() {
|
|||||||
const latestTracking = thesis?.latest_tracking;
|
const latestTracking = thesis?.latest_tracking;
|
||||||
const latestFlow = capitalFlow.length > 0 ? capitalFlow[capitalFlow.length - 1] : null;
|
const latestFlow = capitalFlow.length > 0 ? capitalFlow[capitalFlow.length - 1] : null;
|
||||||
const pageName = recommendation?.name || thesis?.name || quote?.name || code;
|
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 (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
@ -212,14 +212,14 @@ export default function StockDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl bg-surface-1/80 border border-border-subtle p-4">
|
<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">
|
<div className="text-xs text-text-secondary leading-relaxed mt-2">
|
||||||
{thesis?.data_freshness.message ?? "加载中"}
|
{thesis ? "已读取最近推荐、跟踪和诊断记录。" : "加载中"}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 mt-3">
|
<div className="grid grid-cols-2 gap-2 mt-3">
|
||||||
<MiniDataCell label="当前动作" value={recommendation?.action_plan || "观察"} />
|
<MiniDataCell label="当前动作" value={recommendation?.action_plan || "观察"} />
|
||||||
<MiniDataCell label="AI 置信" value={aiConviction != null ? `${aiConviction}/10` : "暂无"} />
|
<MiniDataCell label="把握度" value={conviction != null ? `${conviction}/10` : "暂无"} />
|
||||||
<MiniDataCell label="AI预筛" value={recommendation?.prefilter_decision || "暂无"} />
|
<MiniDataCell label="初步判断" value={formatPrefilterDecision(recommendation?.prefilter_decision)} />
|
||||||
<MiniDataCell label="建议仓位" value={recommendation?.suggested_position_pct != null ? `${recommendation.suggested_position_pct}%` : "未设置"} />
|
<MiniDataCell label="建议仓位" value={recommendation?.suggested_position_pct != null ? `${recommendation.suggested_position_pct}%` : "未设置"} />
|
||||||
</div>
|
</div>
|
||||||
{(recommendation?.recall_tags?.length ?? 0) > 0 ? (
|
{(recommendation?.recall_tags?.length ?? 0) > 0 ? (
|
||||||
@ -232,7 +232,7 @@ export default function StockDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex flex-wrap items-center gap-3 mt-3 text-[11px] text-text-muted">
|
<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?.data_freshness.tracking_date || "暂无"}</span>
|
||||||
<span>诊断 {thesis?.diagnoses?.length ? `${thesis.diagnoses.length}条` : "暂无"}</span>
|
<span>诊断 {thesis?.diagnoses?.length ? `${thesis.diagnoses.length}条` : "暂无"}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -240,7 +240,7 @@ export default function StockDetailPage() {
|
|||||||
href={`/diagnose?code=${code}`}
|
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"
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -280,8 +280,8 @@ export default function StockDetailPage() {
|
|||||||
{evidenceLoaded ? (
|
{evidenceLoaded ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-fade-in-up">
|
<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线缓存数据" />}
|
{kline.length > 0 ? <KlineChart data={kline as never[]} /> : <ChartEmptyCard title="K线图" description="暂无K线数据" />}
|
||||||
{capitalFlow.length > 0 ? <CapitalFlowChart data={capitalFlow} /> : <ChartEmptyCard title="资金流向趋势" description="暂无资金流缓存数据" />}
|
{capitalFlow.length > 0 ? <CapitalFlowChart data={capitalFlow} /> : <ChartEmptyCard title="资金流向趋势" description="暂无资金流数据" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{latestFlow ? (
|
{latestFlow ? (
|
||||||
@ -308,19 +308,19 @@ function PlanCard({
|
|||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
<SectionTitle title="执行计划" />
|
<SectionTitle title="执行计划" />
|
||||||
{recommendation?.llm_score != null ? (
|
{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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 text-sm">
|
<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 ? (
|
{(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}
|
) : null}
|
||||||
<PlanRow label="触发条件" value={recommendation?.trigger_condition || "暂无明确触发条件"} />
|
<PlanRow label="触发条件" value={recommendation?.trigger_condition || "暂无明确触发条件"} />
|
||||||
<PlanRow label="失效条件" value={recommendation?.invalidation_condition || "暂无明确失效条件"} />
|
<PlanRow label="失效条件" value={recommendation?.invalidation_condition || "暂无明确失效条件"} />
|
||||||
<PlanRow label="建议仓位" value={recommendation?.suggested_position_pct != null ? `${recommendation.suggested_position_pct}%` : "未设置"} />
|
<PlanRow label="建议仓位" value={recommendation?.suggested_position_pct != null ? `${recommendation.suggested_position_pct}%` : "未设置"} />
|
||||||
<PlanRow label="复盘周期" value={recommendation?.review_after_days ? `${recommendation.review_after_days}个交易日` : "未设置"} />
|
<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}
|
{recommendation ? <PlanRow label="规则参考" value={`${Math.round(recommendation.score)} 分(边界证据)`} /> : null}
|
||||||
{trackingNote ? <PlanRow label="跟踪结论" value={trackingNote} /> : null}
|
{trackingNote ? <PlanRow label="跟踪结论" value={trackingNote} /> : null}
|
||||||
</div>
|
</div>
|
||||||
@ -330,8 +330,8 @@ function PlanCard({
|
|||||||
|
|
||||||
function formatRecallTag(tag: string): string {
|
function formatRecallTag(tag: string): string {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
sector_recall: "主线召回",
|
sector_recall: "主线入选",
|
||||||
trend_scan: "趋势召回",
|
trend_scan: "趋势入选",
|
||||||
intraday_active: "盘中异动",
|
intraday_active: "盘中异动",
|
||||||
hot_sector_core: "板块核心",
|
hot_sector_core: "板块核心",
|
||||||
sector_leader: "前排线索",
|
sector_leader: "前排线索",
|
||||||
@ -341,6 +341,15 @@ function formatRecallTag(tag: string): string {
|
|||||||
return labels[tag] ?? tag;
|
return labels[tag] ?? tag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPrefilterDecision(decision?: string | null): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
priority: "优先研究",
|
||||||
|
watch: "保留观察",
|
||||||
|
ignore: "暂不处理",
|
||||||
|
};
|
||||||
|
return labels[decision ?? ""] ?? "暂无";
|
||||||
|
}
|
||||||
|
|
||||||
function EvidenceCard({
|
function EvidenceCard({
|
||||||
recommendation,
|
recommendation,
|
||||||
quote,
|
quote,
|
||||||
@ -394,7 +403,7 @@ function DiagnosisArchiveCard({ diagnoses }: { diagnoses: StockThesisResponse["d
|
|||||||
return (
|
return (
|
||||||
<div className="glass-card-static p-5">
|
<div className="glass-card-static p-5">
|
||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<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>
|
<span className="text-[10px] text-text-muted font-mono tabular-nums">{diagnoses.length}条</span>
|
||||||
</div>
|
</div>
|
||||||
{diagnoses.length ? (
|
{diagnoses.length ? (
|
||||||
@ -409,7 +418,7 @@ function DiagnosisArchiveCard({ diagnoses }: { diagnoses: StockThesisResponse["d
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-text-muted">暂无 AI 诊断归档</div>
|
<div className="text-sm text-text-muted">暂无诊断归档</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -441,7 +450,7 @@ function TrackingCard({ tracking }: { tracking: StockThesisResponse["latest_trac
|
|||||||
function QuoteSnapshot({ quote, evidenceLoaded }: { quote: QuoteData | null; evidenceLoaded: boolean }) {
|
function QuoteSnapshot({ quote, evidenceLoaded }: { quote: QuoteData | null; evidenceLoaded: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="glass-card-static p-5">
|
<div className="glass-card-static p-5">
|
||||||
<SectionTitle title="盘面快照" />
|
<SectionTitle title="盘面表现" />
|
||||||
{quote ? (
|
{quote ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-baseline gap-3 mt-3">
|
<div className="flex items-baseline gap-3 mt-3">
|
||||||
@ -460,7 +469,7 @@ function QuoteSnapshot({ quote, evidenceLoaded }: { quote: QuoteData | null; evi
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : evidenceLoaded ? (
|
) : 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>
|
<div className="text-sm text-text-muted mt-3">点击“加载行情证据”后查看</div>
|
||||||
)}
|
)}
|
||||||
@ -495,7 +504,7 @@ function SignalSnapshot({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : evidenceLoaded ? (
|
) : 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>
|
<div className="text-sm text-text-muted mt-3">点击“加载行情证据”后查看</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -357,8 +357,19 @@ function ConfigCenterPanel({
|
|||||||
function StrategyConfigCard({ item, onRollback }: { item: StrategyConfigRecord; onRollback: (strategyId: string) => void }) {
|
function StrategyConfigCard({ item, onRollback }: { item: StrategyConfigRecord; onRollback: (strategyId: string) => void }) {
|
||||||
const cfg = item.config;
|
const cfg = item.config;
|
||||||
const scoreWeights = cfg.score_weights as Record<string, number> | undefined;
|
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
|
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 (
|
return (
|
||||||
|
|||||||
@ -113,12 +113,12 @@ export default function LoginPage() {
|
|||||||
<div className="flex flex-col items-center mb-8">
|
<div className="flex flex-col items-center mb-8">
|
||||||
<div className="relative mb-5">
|
<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">
|
<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>
|
||||||
<div className="absolute inset-0 rounded-xl border border-amber-500/20 animate-pulse-ring" />
|
<div className="absolute inset-0 rounded-xl border border-amber-500/20 animate-pulse-ring" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-lg font-bold tracking-tight text-text-primary">Dragon AI Agent</h1>
|
<h1 className="text-lg font-bold tracking-tight text-text-primary">AlphaX Agent</h1>
|
||||||
<p className="text-xs text-text-muted mt-1">A 股智能筛选引擎</p>
|
<p className="text-xs text-text-muted mt-1">Stock</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-card-static p-7 rounded-2xl">
|
<div className="glass-card-static p-7 rounded-2xl">
|
||||||
|
|||||||
@ -17,11 +17,11 @@ export default function LandingPage() {
|
|||||||
<header className="flex items-center justify-between py-6 animate-fade-in-up">
|
<header className="flex items-center justify-between py-6 animate-fade-in-up">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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>
|
<div>
|
||||||
<div className="text-sm font-semibold tracking-tight">Dragon AI Agent</div>
|
<div className="text-sm font-semibold tracking-tight">AlphaX Agent</div>
|
||||||
<div className="text-xs text-text-muted mt-0.5">A 股 AI 作战系统</div>
|
<div className="text-xs text-text-muted mt-0.5">Stock</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ export default function LandingPage() {
|
|||||||
<section className="flex-1 flex items-center py-12 md:py-20">
|
<section className="flex-1 flex items-center py-12 md:py-20">
|
||||||
<div className="w-full max-w-4xl">
|
<div className="w-full max-w-4xl">
|
||||||
<div className="text-[10px] uppercase tracking-[0.24em] text-amber-400 font-semibold animate-fade-in-up">
|
<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>
|
</div>
|
||||||
|
|
||||||
<h1 className="mt-5 text-4xl md:text-6xl font-bold tracking-tight leading-[1.02] animate-fade-in-up">
|
<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">
|
<p className="mt-6 max-w-2xl text-base md:text-lg text-text-secondary leading-relaxed animate-fade-in-up">
|
||||||
从大盘、主线板块到推荐池和自选股,
|
从大盘、主线板块到推荐池和自选股,
|
||||||
用 AI 帮你整理成一套清晰、连续、可跟踪的交易研究结果。
|
帮你整理成一套清晰、连续、可跟踪的交易研究结果。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 mt-8 animate-fade-in-up">
|
<div className="flex flex-wrap gap-3 mt-8 animate-fade-in-up">
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import { AuthProvider } from "@/hooks/use-auth";
|
|||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Dragon AI Agent",
|
title: "AlphaX Agent | Stock",
|
||||||
description: "A 股智能筛选引擎,盘中实时分析与推荐",
|
description: "A 股投研作战台,聚焦市场主线、资金流与交易决策",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
|
|||||||
@ -94,7 +94,7 @@ export default function MarketTemp({ data, indices }: MarketTempProps) {
|
|||||||
|
|
||||||
{!limitCountsReliable && (
|
{!limitCountsReliable && (
|
||||||
<div className="text-[10px] text-text-muted/60 mb-2">
|
<div className="text-[10px] text-text-muted/60 mb-2">
|
||||||
涨停/跌停池实时计数暂未确认,当前未展示不可靠的 0 值。
|
涨停/跌停池暂未确认,当前不展示不可靠的数值。
|
||||||
</div>
|
</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 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">
|
<div className="text-[10px] sm:text-xs text-text-muted mb-0.5 truncate">
|
||||||
{idx.name}
|
{idx.name}
|
||||||
{idx.realtime && <span className="text-emerald-400/60 ml-0.5">·实时</span>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-baseline gap-0.5 sm:gap-1.5">
|
<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"}`}>
|
<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"}`}>
|
||||||
|
|||||||
@ -118,12 +118,12 @@ export function SidebarNav() {
|
|||||||
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐池" />
|
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐池" />
|
||||||
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块主线" />
|
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块主线" />
|
||||||
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} 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="个股诊断" />
|
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="个股诊断" />
|
||||||
{user?.role === "admin" && (
|
{user?.role === "admin" && (
|
||||||
<>
|
<>
|
||||||
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="系统校准" />
|
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="策略校准" />
|
||||||
<SideNavItem href="/settings" icon={<SettingsIcon />} label="系统设置" />
|
<SideNavItem href="/settings" icon={<SettingsIcon />} label="管理设置" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
@ -159,7 +159,7 @@ export function MobileBottomNav() {
|
|||||||
<MobileNavItem href="/recommendations" label="推荐池">
|
<MobileNavItem href="/recommendations" label="推荐池">
|
||||||
<TargetIcon />
|
<TargetIcon />
|
||||||
</MobileNavItem>
|
</MobileNavItem>
|
||||||
<MobileNavItem href="/chat" label="智能体">
|
<MobileNavItem href="/chat" label="助手">
|
||||||
<ChatIcon />
|
<ChatIcon />
|
||||||
</MobileNavItem>
|
</MobileNavItem>
|
||||||
<MobileNavItem href="/watchlists" label="自选">
|
<MobileNavItem href="/watchlists" label="自选">
|
||||||
|
|||||||
@ -36,19 +36,12 @@ export default function SectorHeatmap({ sectors }: { sectors: SectorData[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mainSectors = sectors.slice(0, 3);
|
const mainSectors = sectors.slice(0, 3);
|
||||||
const hasRealtime = sectors.some((s) => s.is_realtime);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card-static p-4 sm:p-5 animate-fade-in-up">
|
<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 className="flex items-start justify-between gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-text-muted">今日主线</h2>
|
<h2 className="text-sm font-semibold text-text-muted">今日主线</h2>
|
||||||
</div>
|
</div>
|
||||||
{hasRealtime && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400/80">
|
|
||||||
今日实时
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@ -104,6 +97,11 @@ export default function SectorHeatmap({ sectors }: { sectors: SectorData[] }) {
|
|||||||
阶段 {sector.stage}
|
阶段 {sector.stage}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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] ? (
|
{leaders[0] ? (
|
||||||
<span className="px-2 py-1 rounded-lg bg-surface-2 text-text-secondary">
|
<span className="px-2 py-1 rounded-lg bg-surface-2 text-text-secondary">
|
||||||
前排 {leaders.slice(0, 2).map((item) => item.name).join(" / ")}
|
前排 {leaders.slice(0, 2).map((item) => item.name).join(" / ")}
|
||||||
|
|||||||
@ -1,314 +1,115 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { getLevelBadge } from "@/lib/utils";
|
|
||||||
import type { RecommendationData } from "@/lib/api";
|
import type { RecommendationData } from "@/lib/api";
|
||||||
|
|
||||||
export default function StockCard({ rec, compact = false }: { rec: RecommendationData; compact?: boolean }) {
|
export default function StockCard({ rec, compact = false }: { rec: RecommendationData; compact?: boolean }) {
|
||||||
const badge = getLevelBadge(rec.level);
|
const action = getActionMeta(rec.action_plan);
|
||||||
const aiConviction = rec.llm_score != null ? Math.round(rec.llm_score) : null;
|
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> = {
|
const recallLabels: Record<string, string> = {
|
||||||
sector_recall: "主线召回",
|
sector_recall: "主线入选",
|
||||||
trend_scan: "趋势召回",
|
trend_scan: "趋势入选",
|
||||||
intraday_active: "盘中异动",
|
intraday_active: "盘中异动",
|
||||||
hot_theme_core: "主题核心",
|
hot_theme_core: "主题核心",
|
||||||
theme_leader: "主题前排",
|
theme_leader: "主题前排",
|
||||||
top_theme_member: "主线主题成分",
|
top_theme_member: "主线成分",
|
||||||
moneyflow_support: "资金支撑",
|
moneyflow_support: "资金支撑",
|
||||||
volume_active: "量能活跃",
|
volume_active: "量能活跃",
|
||||||
};
|
};
|
||||||
const prefilterLabel: Record<string, string> = {
|
|
||||||
priority: "AI优先深看",
|
|
||||||
watch: "AI保留观察",
|
|
||||||
ignore: "AI建议忽略",
|
|
||||||
"": "待AI预筛",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 入场信号标签
|
return [
|
||||||
const signalTypeMap: Record<string, { label: string; style: string }> = {
|
rec.entry_signal_type ? signalTypeLabel(rec.entry_signal_type) : null,
|
||||||
breakout: { label: "突破型", style: "bg-red-500/15 text-red-400 border-red-500/20" },
|
rec.review_after_days ? `${rec.review_after_days}日复盘` : null,
|
||||||
pullback: { label: "回踩型", style: "bg-blue-500/15 text-blue-400 border-blue-500/20" },
|
conviction != null ? `把握 ${conviction}/10` : null,
|
||||||
launch: { label: "启动型", style: "bg-orange-500/15 text-orange-400 border-orange-500/20" },
|
...(rec.recall_tags ?? []).map((tag) => recallLabels[tag] ?? tag),
|
||||||
};
|
].filter(Boolean) as string[];
|
||||||
// 向后兼容:旧数据使用 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrackingMetric({ label, value }: { label: string; value: number | null }) {
|
function signalTypeLabel(type: string) {
|
||||||
const num = value ?? 0;
|
const labels: Record<string, string> = {
|
||||||
const color = num > 0 ? "text-red-400" : num < 0 ? "text-emerald-400" : "text-text-secondary";
|
breakout: "突破",
|
||||||
return (
|
pullback: "回踩",
|
||||||
<div>
|
launch: "启动",
|
||||||
<div className="text-[10px] text-text-muted/60 mb-0.5">{label}</div>
|
};
|
||||||
<div className={`text-xs font-mono tabular-nums font-semibold ${color}`}>
|
return labels[type] ?? type;
|
||||||
{num > 0 ? "+" : ""}{num.toFixed(2)}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -144,6 +144,7 @@ export interface RecommendationData {
|
|||||||
prefilter_decision?: "priority" | "watch" | "ignore" | "";
|
prefilter_decision?: "priority" | "watch" | "ignore" | "";
|
||||||
prefilter_reason?: string;
|
prefilter_reason?: string;
|
||||||
focus_points?: string[];
|
focus_points?: string[];
|
||||||
|
decision_trace?: DecisionTrace;
|
||||||
scan_session: string;
|
scan_session: string;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
entry_timing?: string;
|
entry_timing?: string;
|
||||||
@ -157,6 +158,34 @@ export interface RecommendationData {
|
|||||||
tracking?: RecommendationTrackingSummary | null;
|
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 {
|
export interface RecommendationTrackingSummary {
|
||||||
current_price: number | null;
|
current_price: number | null;
|
||||||
pct_from_entry: number | null;
|
pct_from_entry: number | null;
|
||||||
@ -206,6 +235,11 @@ export interface SectorData {
|
|||||||
data_mode?: "realtime_today" | "realtime_overlay" | "daily_snapshot";
|
data_mode?: "realtime_today" | "realtime_overlay" | "daily_snapshot";
|
||||||
structure_trade_date?: string;
|
structure_trade_date?: string;
|
||||||
source?: "eastmoney" | "sina" | "snapshot" | 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 {
|
export interface LatestResult {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user