1
This commit is contained in:
parent
d9e77a7bec
commit
9254b4aede
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -11,6 +11,7 @@ from app.data.tushare_client import tushare_client
|
||||
from app.data import tencent_client
|
||||
from app.data.models import SectorInfo, CapitalFlow
|
||||
from app.config import settings
|
||||
from app.analysis.sector_alignment import build_hot_theme_membership
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -142,18 +143,12 @@ async def filter_stocks_by_capital(
|
||||
trade_date = tushare_client.get_latest_trade_date()
|
||||
|
||||
# 收集所有热门板块的成分股(去重)
|
||||
stock_sectors: dict[str, list[str]] = {} # con_code -> [板块名]
|
||||
for sector in hot_sectors:
|
||||
members = tushare_client.get_ths_members(sector.sector_code)
|
||||
if members.empty or "con_code" not in members.columns:
|
||||
stock_sectors: dict[str, list[str]] = {}
|
||||
sector_member_codes, sector_code_map, _, _, _ = build_hot_theme_membership(hot_sectors)
|
||||
for code in sector_member_codes:
|
||||
if not code or "." not in str(code):
|
||||
continue
|
||||
for _, m in members.iterrows():
|
||||
code = m.get("con_code", "")
|
||||
if not code or "." not in str(code):
|
||||
continue
|
||||
if code not in stock_sectors:
|
||||
stock_sectors[code] = []
|
||||
stock_sectors[code].append(sector.sector_name)
|
||||
stock_sectors.setdefault(code, []).append(sector_code_map.get(code, ""))
|
||||
|
||||
if not stock_sectors:
|
||||
logger.warning("热门板块成分股为空")
|
||||
|
||||
@ -27,6 +27,7 @@ from app.data.eastmoney_client import (
|
||||
get_a_share_realtime_ranking,
|
||||
)
|
||||
from app.analysis.sector_scanner import scan_hot_sectors
|
||||
from app.analysis.sector_alignment import build_hot_theme_membership
|
||||
from app.analysis.technical import add_all_indicators
|
||||
from app.analysis.signals import generate_signals
|
||||
from app.analysis.capital_flow import _score_valuation
|
||||
@ -50,7 +51,7 @@ async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTem
|
||||
limit_down_count = prev_temp.limit_down_count
|
||||
|
||||
eastmoney_quotes = await get_a_share_realtime_ranking(page_size=6000)
|
||||
if eastmoney_quotes:
|
||||
if eastmoney_quotes and len(eastmoney_quotes) >= 3000:
|
||||
up_count = sum(1 for q in eastmoney_quotes if q.get("pct_chg", 0) > 0)
|
||||
down_count = sum(1 for q in eastmoney_quotes if q.get("pct_chg", 0) < 0)
|
||||
limit_up_count = sum(1 for q in eastmoney_quotes if q.get("pct_chg", 0) >= _limit_threshold(q.get("ts_code", "")))
|
||||
@ -64,7 +65,7 @@ async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTem
|
||||
len(eastmoney_quotes),
|
||||
)
|
||||
else:
|
||||
logger.warning("东方财富全市场实时行情为空,尝试腾讯批量行情补充涨跌家数")
|
||||
logger.warning("东方财富全市场实时行情不足以代表全市场(%s只),尝试腾讯批量行情补充涨跌家数", len(eastmoney_quotes))
|
||||
|
||||
try:
|
||||
if not eastmoney_quotes:
|
||||
@ -179,17 +180,11 @@ async def intraday_filter_stocks(
|
||||
"""
|
||||
# 收集热门板块成分股
|
||||
stock_sectors: dict[str, list[str]] = {}
|
||||
for sector in hot_sectors:
|
||||
members = tushare_client.get_ths_members(sector.sector_code)
|
||||
if members.empty or "con_code" not in members.columns:
|
||||
sector_member_codes, sector_code_map, _, _, _ = build_hot_theme_membership(hot_sectors)
|
||||
for code in sector_member_codes:
|
||||
if not code or "." not in str(code):
|
||||
continue
|
||||
for _, m in members.iterrows():
|
||||
code = m.get("con_code", "")
|
||||
if not code or "." not in str(code):
|
||||
continue
|
||||
if code not in stock_sectors:
|
||||
stock_sectors[code] = []
|
||||
stock_sectors[code].append(sector.sector_name)
|
||||
stock_sectors.setdefault(code, []).append(sector_code_map.get(code, ""))
|
||||
|
||||
if not stock_sectors:
|
||||
logger.warning("盘中筛选: 热门板块成分股为空")
|
||||
|
||||
@ -8,6 +8,8 @@ import logging
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
from app.data import tencent_client
|
||||
from app.data.market_breadth_client import get_market_breadth
|
||||
from app.data.tushare_client import tushare_client
|
||||
from app.data.models import MarketTemperature
|
||||
|
||||
@ -178,3 +180,34 @@ def calculate_market_temperature(trade_date: str = None) -> MarketTemperature:
|
||||
)
|
||||
logger.info(f"市场温度 {trade_date}: {temperature:.1f} (涨{up_count}/跌{down_count}, 涨停{limit_up_count}, 连板{max_streak})")
|
||||
return result
|
||||
|
||||
|
||||
async def build_realtime_market_temperature(
|
||||
baseline: MarketTemperature | None = None,
|
||||
) -> tuple[MarketTemperature, bool]:
|
||||
"""基于统一市场广度客户端构建实时市场温度。"""
|
||||
baseline = baseline or calculate_market_temperature()
|
||||
breadth = await get_market_breadth()
|
||||
if not breadth.reliable:
|
||||
return baseline, False
|
||||
|
||||
index_data = await tencent_client.get_index_realtime()
|
||||
sh_index = index_data.get("000001.SH")
|
||||
pct = sh_index.get("pct_chg", 0) if sh_index else 0
|
||||
|
||||
ratio = breadth.up_count / max(breadth.down_count, 1)
|
||||
# 实时口径里,把涨停/跌停降级为可选增强项。
|
||||
# 主判断只依赖上涨/下跌家数与指数方向,避免涨停池接口不稳影响系统决策。
|
||||
temp_from_ratio = min(ratio / 3.0 * 40, 40)
|
||||
temp_from_limit_up = min(breadth.limit_up_count / 3, 10) if breadth.limit_counts_reliable else 0
|
||||
temp_from_index = min(max(pct * 10 + 15, 0), 30)
|
||||
|
||||
baseline.trade_date = breadth.trade_date
|
||||
baseline.up_count = breadth.up_count
|
||||
baseline.down_count = breadth.down_count
|
||||
if breadth.limit_counts_reliable:
|
||||
baseline.limit_up_count = breadth.limit_up_count
|
||||
baseline.limit_down_count = breadth.limit_down_count
|
||||
baseline.temperature = round(min(max(temp_from_ratio + temp_from_limit_up + temp_from_index + 20, 0), 100), 1)
|
||||
baseline.index_above_ma20 = pct > 0 if sh_index else baseline.index_above_ma20
|
||||
return baseline, True
|
||||
|
||||
208
backend/app/analysis/sector_alignment.py
Normal file
208
backend/app/analysis/sector_alignment.py
Normal file
@ -0,0 +1,208 @@
|
||||
"""市场主题与个股/成分映射辅助。
|
||||
|
||||
解决两个问题:
|
||||
1. 实时板块榜(东方财富行业/概念)与 Tushare 板块代码体系不一致。
|
||||
2. 个股行业名、概念指数名与系统内部 MarketTheme 存在口径差异。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.data.models import SectorInfo
|
||||
from app.data.tushare_client import tushare_client
|
||||
from app.analysis.theme_mapper import resolve_theme
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_sector_name(name: str) -> str:
|
||||
return (
|
||||
(name or "")
|
||||
.replace("申万", "")
|
||||
.replace("同花顺", "")
|
||||
.replace("行业", "")
|
||||
.replace("板块", "")
|
||||
.replace("概念", "")
|
||||
.strip()
|
||||
)
|
||||
|
||||
|
||||
def normalize_ts_code(code: str) -> str:
|
||||
raw = str(code or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if "." in raw:
|
||||
return raw
|
||||
if len(raw) >= 6 and raw[:6].isdigit():
|
||||
symbol = raw[:6]
|
||||
return f"{symbol}.SH" if symbol.startswith("6") else f"{symbol}.SZ"
|
||||
return raw
|
||||
|
||||
|
||||
def sector_name_matches(left: str, right: str) -> bool:
|
||||
a = normalize_sector_name(left)
|
||||
b = normalize_sector_name(right)
|
||||
if not a or not b:
|
||||
return False
|
||||
if a == b:
|
||||
return True
|
||||
short, long = (a, b) if len(a) <= len(b) else (b, a)
|
||||
return short in long
|
||||
|
||||
|
||||
def sector_name_strict_match(left: str, right: str) -> bool:
|
||||
a = normalize_sector_name(left)
|
||||
b = normalize_sector_name(right)
|
||||
if not a or not b:
|
||||
return False
|
||||
return a == b
|
||||
|
||||
|
||||
def _theme_match_names(theme: SectorInfo) -> list[str]:
|
||||
return [
|
||||
theme.sector_name,
|
||||
theme.theme_name,
|
||||
*(theme.theme_aliases or []),
|
||||
]
|
||||
|
||||
|
||||
def find_hot_theme_match(name: str, hot_themes: list[SectorInfo]) -> SectorInfo | None:
|
||||
"""把任意行业/概念/主题名称匹配到今日系统主题。"""
|
||||
if not name:
|
||||
return None
|
||||
|
||||
resolved_id, resolved_name, resolved_aliases = resolve_theme(name)
|
||||
for theme in hot_themes:
|
||||
if resolved_id and theme.theme_id and resolved_id == theme.theme_id:
|
||||
return theme
|
||||
names = _theme_match_names(theme)
|
||||
if any(sector_name_strict_match(name, candidate) for candidate in names):
|
||||
return theme
|
||||
if any(sector_name_strict_match(resolved_name, candidate) for candidate in names):
|
||||
return theme
|
||||
if any(
|
||||
sector_name_strict_match(alias, candidate)
|
||||
for alias in resolved_aliases
|
||||
for candidate in names
|
||||
):
|
||||
return theme
|
||||
return None
|
||||
|
||||
|
||||
def build_hot_theme_membership(
|
||||
hot_themes: list[SectorInfo],
|
||||
) -> tuple[set[str], dict[str, str], dict[str, str], dict[str, int], set[str]]:
|
||||
"""为今日主线主题构造成分股映射。
|
||||
|
||||
返回:
|
||||
- sector_member_codes: 所有成分股代码
|
||||
- sector_code_map: ts_code -> 主题名
|
||||
- sector_stage_map: 主题名 -> 阶段
|
||||
- sector_rank_map: 主题名 -> 排名
|
||||
- leader_codes: 领涨股代码
|
||||
"""
|
||||
sector_member_codes: set[str] = set()
|
||||
sector_code_map: dict[str, str] = {}
|
||||
sector_stage_map: dict[str, str] = {}
|
||||
sector_rank_map: dict[str, int] = {}
|
||||
leader_codes: set[str] = set()
|
||||
|
||||
stock_basic = tushare_client.get_stock_basic()
|
||||
industry_buckets: dict[str, list[str]] = {}
|
||||
if not stock_basic.empty and "industry" in stock_basic.columns:
|
||||
for _, row in stock_basic.iterrows():
|
||||
code = str(row.get("ts_code") or "")
|
||||
industry = str(row.get("industry") or "")
|
||||
if not code or not industry:
|
||||
continue
|
||||
industry_buckets.setdefault(industry, []).append(code)
|
||||
|
||||
concept_index = tushare_client.get_ths_index_list("N")
|
||||
industry_index = tushare_client.get_ths_index_list("I")
|
||||
|
||||
concept_map = {
|
||||
str(row.get("name") or ""): str(row.get("ts_code") or "")
|
||||
for _, row in concept_index.iterrows()
|
||||
if row.get("name") and row.get("ts_code")
|
||||
} if not concept_index.empty else {}
|
||||
industry_index_map = {
|
||||
str(row.get("name") or ""): str(row.get("ts_code") or "")
|
||||
for _, row in industry_index.iterrows()
|
||||
if row.get("name") and row.get("ts_code")
|
||||
} if not industry_index.empty else {}
|
||||
|
||||
concept_theme_ids = {
|
||||
"ai_compute",
|
||||
"robotics",
|
||||
"battery_lithium",
|
||||
"media_games",
|
||||
}
|
||||
|
||||
for idx, theme in enumerate(hot_themes):
|
||||
theme_name = theme.theme_name or theme.sector_name
|
||||
theme_aliases = _theme_match_names(theme)
|
||||
sector_rank_map[theme_name] = idx + 1
|
||||
sector_stage_map[theme_name] = theme.stage
|
||||
|
||||
for leader in (theme.leading_stocks_realtime or theme.leading_stocks or []):
|
||||
leader_code = normalize_ts_code(str(leader.get("ts_code", "")).strip())
|
||||
if leader_code:
|
||||
leader_codes.add(leader_code)
|
||||
sector_member_codes.add(leader_code)
|
||||
sector_code_map.setdefault(leader_code, theme_name)
|
||||
|
||||
resolved_codes: set[str] = set()
|
||||
strict_industry_codes: set[str] = set()
|
||||
strict_concept_codes: set[str] = set()
|
||||
industry_codes: set[str] = set()
|
||||
concept_codes: set[str] = set()
|
||||
|
||||
for industry_name, codes in industry_buckets.items():
|
||||
if any(sector_name_strict_match(alias, industry_name) for alias in theme_aliases):
|
||||
strict_industry_codes.update(codes)
|
||||
industry_codes.update(codes)
|
||||
elif any(sector_name_matches(alias, industry_name) for alias in theme_aliases):
|
||||
resolved_codes.update(codes)
|
||||
industry_codes.update(codes)
|
||||
|
||||
for index_name, index_code in industry_index_map.items():
|
||||
if not any(sector_name_matches(alias, index_name) for alias in theme_aliases):
|
||||
continue
|
||||
members_df = tushare_client.get_ths_members(index_code)
|
||||
if not members_df.empty and "con_code" in members_df.columns:
|
||||
codes = {str(code) for code in members_df["con_code"].tolist() if code}
|
||||
industry_codes.update(codes)
|
||||
if any(sector_name_strict_match(alias, index_name) for alias in theme_aliases):
|
||||
strict_industry_codes.update(codes)
|
||||
else:
|
||||
resolved_codes.update(codes)
|
||||
|
||||
for index_name, index_code in concept_map.items():
|
||||
if not any(sector_name_matches(alias, index_name) for alias in theme_aliases):
|
||||
continue
|
||||
members_df = tushare_client.get_ths_members(index_code)
|
||||
if not members_df.empty and "con_code" in members_df.columns:
|
||||
codes = {str(code) for code in members_df["con_code"].tolist() if code}
|
||||
concept_codes.update(codes)
|
||||
if any(sector_name_strict_match(alias, index_name) for alias in theme_aliases):
|
||||
strict_concept_codes.update(codes)
|
||||
else:
|
||||
resolved_codes.update(codes)
|
||||
|
||||
if industry_codes:
|
||||
final_codes = strict_industry_codes or industry_codes
|
||||
elif theme.theme_id in concept_theme_ids:
|
||||
final_codes = strict_concept_codes or concept_codes or resolved_codes
|
||||
else:
|
||||
final_codes = strict_industry_codes or strict_concept_codes or resolved_codes
|
||||
|
||||
if not final_codes:
|
||||
logger.debug("主线主题未解析到成分股: %s aliases=%s", theme_name, theme_aliases)
|
||||
continue
|
||||
|
||||
sector_member_codes.update(final_codes)
|
||||
for code in final_codes:
|
||||
sector_code_map.setdefault(code, theme_name)
|
||||
|
||||
return sector_member_codes, sector_code_map, sector_stage_map, sector_rank_map, leader_codes
|
||||
@ -10,6 +10,7 @@ from app.config import should_prefer_realtime_today, today_trade_date
|
||||
from app.data.eastmoney_client import get_sector_realtime_ranking
|
||||
from app.data.models import SectorInfo
|
||||
from app.data.tushare_client import tushare_client
|
||||
from app.analysis.theme_mapper import merge_sectors_to_themes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -34,6 +35,8 @@ def _apply_empty_overlay(sector: SectorInfo) -> SectorInfo:
|
||||
sector.leading_stocks_realtime = []
|
||||
sector.is_realtime = False
|
||||
sector.data_mode = "daily_snapshot"
|
||||
sector.source = sector.source or "snapshot"
|
||||
sector.board_type = sector.board_type or "snapshot"
|
||||
return sector
|
||||
|
||||
|
||||
@ -42,6 +45,7 @@ def _sector_from_eastmoney(item: dict) -> SectorInfo:
|
||||
sector = SectorInfo(
|
||||
sector_code=item.get("sector_code", ""),
|
||||
sector_name=item.get("sector_name", ""),
|
||||
board_type=item.get("board_type", "industry"),
|
||||
trade_date=today_trade_date(),
|
||||
pct_change=float(item.get("pct_change", 0) or 0),
|
||||
capital_inflow=0,
|
||||
@ -58,6 +62,7 @@ def _sector_from_eastmoney(item: dict) -> SectorInfo:
|
||||
realtime_down_count=int(item.get("down_count", 0) or 0),
|
||||
is_realtime=True,
|
||||
data_mode="realtime_today",
|
||||
source=item.get("source", "eastmoney"),
|
||||
)
|
||||
if item.get("leading_stock_name"):
|
||||
sector.leading_stocks_realtime = [{
|
||||
@ -71,16 +76,28 @@ def _sector_from_eastmoney(item: dict) -> SectorInfo:
|
||||
|
||||
|
||||
async def get_today_realtime_sector_board(limit: int = 20) -> list[SectorInfo]:
|
||||
"""用东方财富今日板块榜生成展示列表,作为 Tushare/定时扫描滞后的兜底。"""
|
||||
try:
|
||||
em_sectors = await get_sector_realtime_ranking(page_size=max(limit, 20))
|
||||
except Exception:
|
||||
logger.warning("东方财富今日板块榜获取失败")
|
||||
return []
|
||||
"""用实时行业榜 + 概念榜生成展示列表,作为 Tushare/定时扫描滞后的兜底。"""
|
||||
industry_sectors = await get_sector_realtime_ranking(fs="m:90+t:2", page_size=max(limit, 20), notify=False)
|
||||
concept_sectors = await get_sector_realtime_ranking(fs="m:90+t:3", page_size=max(limit, 20), notify=False)
|
||||
em_sectors = industry_sectors + concept_sectors
|
||||
if not em_sectors:
|
||||
try:
|
||||
from app.data.sina_client import get_sector_realtime_ranking_by_industry
|
||||
em_sectors = await get_sector_realtime_ranking_by_industry(limit=max(limit, 20))
|
||||
except Exception as e:
|
||||
logger.warning("新浪行业实时榜兜底失败: %s", e)
|
||||
em_sectors = []
|
||||
|
||||
sectors = [_sector_from_eastmoney(item) for item in em_sectors[:limit]]
|
||||
sectors.sort(key=lambda s: s.realtime_pct_change if s.realtime_pct_change is not None else s.pct_change, reverse=True)
|
||||
return sectors
|
||||
deduped = {}
|
||||
for item in em_sectors:
|
||||
key = f"{item.get('board_type', 'industry')}::{item.get('sector_name', '')}"
|
||||
existing = deduped.get(key)
|
||||
if existing is None or float(item.get("pct_change", 0) or 0) > float(existing.get("pct_change", 0) or 0):
|
||||
deduped[key] = item
|
||||
|
||||
sectors = [_sector_from_eastmoney(item) for item in deduped.values()]
|
||||
sectors = merge_sectors_to_themes(sectors, limit=limit)
|
||||
return sectors[:limit]
|
||||
|
||||
|
||||
async def enrich_sectors_with_realtime(sectors: list[SectorInfo]) -> list[SectorInfo]:
|
||||
@ -90,16 +107,16 @@ async def enrich_sectors_with_realtime(sectors: list[SectorInfo]) -> list[Sector
|
||||
|
||||
latest_trade_date = sectors[0].trade_date or tushare_client.get_latest_trade_date()
|
||||
if not should_prefer_realtime_today(latest_trade_date):
|
||||
return [_apply_empty_overlay(sector) for sector in sectors]
|
||||
return merge_sectors_to_themes([_apply_empty_overlay(sector) for sector in sectors], limit=len(sectors))
|
||||
|
||||
try:
|
||||
em_sectors = await get_sector_realtime_ranking()
|
||||
except Exception:
|
||||
logger.warning("东方财富板块实时数据获取失败,回退到日级快照")
|
||||
return [_apply_empty_overlay(sector) for sector in sectors]
|
||||
return merge_sectors_to_themes([_apply_empty_overlay(sector) for sector in sectors], limit=len(sectors))
|
||||
|
||||
if not em_sectors:
|
||||
return [_apply_empty_overlay(sector) for sector in sectors]
|
||||
return merge_sectors_to_themes([_apply_empty_overlay(sector) for sector in sectors], limit=len(sectors))
|
||||
|
||||
em_name_map = {item["sector_name"]: item for item in em_sectors}
|
||||
matched = 0
|
||||
@ -133,7 +150,8 @@ async def enrich_sectors_with_realtime(sectors: list[SectorInfo]) -> list[Sector
|
||||
}]
|
||||
sector.is_realtime = True
|
||||
sector.data_mode = "realtime_overlay"
|
||||
sector.source = em_data.get("source", "eastmoney")
|
||||
|
||||
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)
|
||||
return sectors
|
||||
return merge_sectors_to_themes(sectors, limit=len(sectors))
|
||||
|
||||
186
backend/app/analysis/theme_mapper.py
Normal file
186
backend/app/analysis/theme_mapper.py
Normal file
@ -0,0 +1,186 @@
|
||||
"""市场主线主题归一层。
|
||||
|
||||
不同数据源的板块体系不一致:
|
||||
- 东方财富行业/概念
|
||||
- Tushare/同花顺行业/概念
|
||||
- 股票基础行业
|
||||
|
||||
这里先用可维护的 alias 规则把它们归一成系统自己的 MarketTheme。
|
||||
后续可以把 alias 挪到数据库或配置,并让 LLM 根据复盘结果辅助维护。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from app.data.models import SectorInfo
|
||||
|
||||
|
||||
THEME_ALIASES: dict[str, list[str]] = {
|
||||
"power_energy": ["公用事业", "电力", "绿色电力", "风力发电", "水电", "火电", "核电", "电力行业"],
|
||||
"baijiu_consumer": ["白酒", "白酒Ⅱ", "酿酒", "酿酒概念", "食品饮料", "啤酒", "乳业"],
|
||||
"ship_military": ["航海装备", "航海装备Ⅱ", "船舶", "船舶制造", "国防军工", "军工"],
|
||||
"coal_energy": ["煤炭", "煤炭行业", "煤炭概念", "煤炭开采加工", "焦炭", "煤化工"],
|
||||
"ai_compute": ["人工智能", "算力", "服务器", "数据中心", "CPO", "液冷", "东数西算"],
|
||||
"semiconductor": ["半导体", "芯片", "集成电路", "先进封装", "存储芯片", "光刻机"],
|
||||
"robotics": ["机器人", "人形机器人", "减速器", "工业母机", "自动化设备", "专用机械"],
|
||||
"auto_chain": ["汽车", "汽车零部件", "汽车配件", "新能源车", "智能驾驶", "无人驾驶"],
|
||||
"battery_lithium": ["锂电池", "固态电池", "钠离子电池"],
|
||||
"nonferrous_metals": ["有色金属", "小金属", "贵金属", "黄金"],
|
||||
"media_games": ["传媒", "影视", "影视音像", "游戏", "短剧", "文化传媒"],
|
||||
"tourism_services": ["旅游", "酒店", "景点", "餐饮", "免税"],
|
||||
"chemical_materials": ["化工", "化学制品", "化工原料", "氟化工", "磷化工"],
|
||||
"medicine_health": ["医药", "医疗", "创新药", "中药", "医疗器械", "生物制品"],
|
||||
}
|
||||
|
||||
THEME_NAMES: dict[str, str] = {
|
||||
"power_energy": "电力能源",
|
||||
"baijiu_consumer": "白酒消费",
|
||||
"ship_military": "船舶军工",
|
||||
"coal_energy": "煤炭能源",
|
||||
"ai_compute": "AI算力",
|
||||
"semiconductor": "半导体",
|
||||
"robotics": "机器人装备",
|
||||
"auto_chain": "汽车产业链",
|
||||
"battery_lithium": "锂电池",
|
||||
"nonferrous_metals": "有色金属",
|
||||
"media_games": "传媒游戏",
|
||||
"tourism_services": "旅游服务",
|
||||
"chemical_materials": "化工材料",
|
||||
"medicine_health": "医药健康",
|
||||
}
|
||||
|
||||
EXCLUDED_THEME_KEYWORDS = (
|
||||
"昨日",
|
||||
"连板",
|
||||
"首板",
|
||||
"炸板",
|
||||
"涨停",
|
||||
"跌停",
|
||||
"情绪",
|
||||
"微盘",
|
||||
"昨日触板",
|
||||
"昨日涨停",
|
||||
"昨日首板",
|
||||
)
|
||||
|
||||
|
||||
def _unique_ordered(items: list[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for item in items:
|
||||
value = str(item or "").strip()
|
||||
if not value or value in seen:
|
||||
continue
|
||||
seen.add(value)
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
|
||||
def _clean_name(name: str) -> str:
|
||||
cleaned = (name or "")
|
||||
cleaned = cleaned.replace("行业", "").replace("板块", "").replace("概念", "")
|
||||
cleaned = cleaned.replace("Ⅰ", "").replace("Ⅱ", "").replace("Ⅲ", "").replace("IV", "")
|
||||
return re.sub(r"[\s_\-]+", "", cleaned)
|
||||
|
||||
|
||||
def resolve_theme(name: str) -> tuple[str, str, list[str]]:
|
||||
clean = _clean_name(name)
|
||||
for theme_id, aliases in THEME_ALIASES.items():
|
||||
for alias in aliases:
|
||||
alias_clean = _clean_name(alias)
|
||||
if clean == alias_clean:
|
||||
return theme_id, THEME_NAMES[theme_id], aliases
|
||||
fallback_id = f"raw_{clean}" if clean else "unknown"
|
||||
return fallback_id, name or "未归一主题", [name] if name else []
|
||||
|
||||
|
||||
def apply_theme(sector: SectorInfo) -> SectorInfo:
|
||||
theme_id, theme_name, aliases = resolve_theme(sector.sector_name)
|
||||
sector.theme_id = theme_id
|
||||
sector.theme_name = theme_name
|
||||
sector.theme_aliases = [sector.sector_name] if sector.sector_name else []
|
||||
return sector
|
||||
|
||||
|
||||
def is_excluded_theme_name(name: str) -> bool:
|
||||
value = str(name or "").strip()
|
||||
return any(keyword in value for keyword in EXCLUDED_THEME_KEYWORDS)
|
||||
|
||||
|
||||
def is_valid_theme(sector: SectorInfo) -> bool:
|
||||
if not sector.theme_id:
|
||||
return False
|
||||
if sector.theme_id.startswith("raw_"):
|
||||
return False
|
||||
return not is_excluded_theme_name(sector.sector_name)
|
||||
|
||||
|
||||
def merge_sectors_to_themes(sectors: list[SectorInfo], limit: int = 20) -> list[SectorInfo]:
|
||||
"""把行业/概念板块合并成主题级列表。
|
||||
|
||||
返回仍使用 SectorInfo,便于兼容现有推荐链路;但 sector_name 会变成主题名,
|
||||
theme_aliases 里保留原始 alias 关系,leading_stocks 合并去重。
|
||||
"""
|
||||
grouped: dict[str, SectorInfo] = {}
|
||||
|
||||
for raw in sectors:
|
||||
if is_excluded_theme_name(raw.sector_name):
|
||||
continue
|
||||
sector = apply_theme(raw)
|
||||
if not is_valid_theme(sector):
|
||||
continue
|
||||
key = sector.theme_id or sector.sector_name
|
||||
pct = float(sector.realtime_pct_change if sector.realtime_pct_change is not None else sector.pct_change)
|
||||
existing = grouped.get(key)
|
||||
|
||||
if existing is None:
|
||||
sector.sector_name = sector.theme_name or sector.sector_name
|
||||
sector.board_type = "theme"
|
||||
sector.sector_code = sector.theme_id or sector.sector_code
|
||||
sector.theme_aliases = _unique_ordered([raw.sector_name])
|
||||
grouped[key] = sector
|
||||
continue
|
||||
|
||||
existing_pct = float(existing.realtime_pct_change if existing.realtime_pct_change is not None else existing.pct_change)
|
||||
existing.realtime_pct_change = max(existing_pct, pct) if existing.realtime_pct_change is not None or sector.realtime_pct_change is not None else None
|
||||
existing.pct_change = max(existing.pct_change, sector.pct_change)
|
||||
existing.heat_score = max(existing.heat_score, sector.heat_score)
|
||||
existing.capital_inflow += sector.capital_inflow
|
||||
existing.limit_up_count += sector.limit_up_count
|
||||
existing.member_count += sector.member_count
|
||||
existing.days_continuous = max(existing.days_continuous, sector.days_continuous)
|
||||
existing.turnover_avg = max(existing.turnover_avg, sector.turnover_avg)
|
||||
existing.main_force_ratio = max(existing.main_force_ratio, sector.main_force_ratio)
|
||||
existing.realtime_amount = (existing.realtime_amount or 0) + (sector.realtime_amount or 0)
|
||||
existing.realtime_up_count = (existing.realtime_up_count or 0) + (sector.realtime_up_count or 0)
|
||||
existing.realtime_down_count = (existing.realtime_down_count or 0) + (sector.realtime_down_count or 0)
|
||||
existing.theme_aliases = _unique_ordered([*(existing.theme_aliases or []), raw.sector_name])
|
||||
existing.is_realtime = existing.is_realtime or sector.is_realtime
|
||||
if existing.data_mode == "daily_snapshot" and sector.data_mode != "daily_snapshot":
|
||||
existing.data_mode = sector.data_mode
|
||||
if existing.source != sector.source:
|
||||
existing.source = "mixed"
|
||||
|
||||
leaders = {}
|
||||
for item in (existing.leading_stocks_realtime or existing.leading_stocks or []):
|
||||
if item.get("ts_code"):
|
||||
leaders[item["ts_code"]] = item
|
||||
for item in (sector.leading_stocks_realtime or sector.leading_stocks or []):
|
||||
if item.get("ts_code"):
|
||||
leaders[item["ts_code"]] = item
|
||||
merged_leaders = sorted(leaders.values(), key=lambda item: float(item.get("pct_chg", 0) or 0), reverse=True)[:5]
|
||||
existing.leading_stocks_realtime = merged_leaders
|
||||
existing.leading_stocks = merged_leaders
|
||||
if existing.stage == "end" or (existing.stage == "late" and sector.stage in ("early", "mid")):
|
||||
existing.stage = sector.stage
|
||||
|
||||
result = list(grouped.values())
|
||||
result.sort(
|
||||
key=lambda item: (
|
||||
float(item.realtime_pct_change if item.realtime_pct_change is not None else item.pct_change),
|
||||
float(item.realtime_amount or 0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return result[:limit]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -6,6 +6,8 @@ from fastapi import APIRouter, Depends
|
||||
|
||||
from app.data.tushare_client import tushare_client
|
||||
from app.data import tencent_client
|
||||
from app.data.market_breadth_client import get_market_breadth
|
||||
from app.analysis.market_temp import build_realtime_market_temperature, calculate_market_temperature
|
||||
from app.engine.recommender import get_latest_recommendations
|
||||
from app.config import is_trading_hours, is_market_session, should_prefer_realtime_today, today_trade_date
|
||||
from app.core.deps import get_current_admin
|
||||
@ -15,9 +17,17 @@ router = APIRouter(prefix="/api/market", tags=["market"])
|
||||
|
||||
@router.get("/temperature")
|
||||
async def get_temperature():
|
||||
"""获取市场温度(只读缓存,不触发扫描)"""
|
||||
"""获取市场温度。
|
||||
|
||||
交易日 09:15 后优先做轻量实时计算,不触发完整扫描或 LLM。
|
||||
"""
|
||||
result = await get_latest_recommendations()
|
||||
mt = result.get("market_temp")
|
||||
realtime_used = False
|
||||
if should_prefer_realtime_today(mt.trade_date if mt else None):
|
||||
baseline = mt or calculate_market_temperature()
|
||||
mt, realtime_used = await build_realtime_market_temperature(baseline)
|
||||
breadth = await get_market_breadth() if realtime_used else None
|
||||
if mt:
|
||||
return {
|
||||
"trade_date": mt.trade_date,
|
||||
@ -30,6 +40,8 @@ async def get_temperature():
|
||||
"broken_rate": mt.broken_rate,
|
||||
"index_above_ma20": getattr(mt, "index_above_ma20", False),
|
||||
"is_trading": is_trading_hours(),
|
||||
"data_mode": "realtime_today" if realtime_used else "daily_snapshot",
|
||||
"limit_counts_reliable": breadth.limit_counts_reliable if breadth else False,
|
||||
}
|
||||
return {
|
||||
"trade_date": "",
|
||||
|
||||
@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends
|
||||
from app.engine.recommender import (
|
||||
refresh_recommendations,
|
||||
get_latest_recommendations,
|
||||
get_latest_market_anomalies,
|
||||
get_recommendation_history,
|
||||
get_performance_stats,
|
||||
)
|
||||
@ -25,10 +26,16 @@ router = APIRouter(prefix="/api/recommendations", tags=["recommendations"])
|
||||
async def get_latest():
|
||||
"""获取最新推荐列表"""
|
||||
result = await get_latest_recommendations()
|
||||
anomalies = await get_latest_market_anomalies()
|
||||
|
||||
mt = result.get("market_temp")
|
||||
try:
|
||||
from app.api.market import get_temperature
|
||||
realtime_temp = await get_temperature()
|
||||
except Exception:
|
||||
realtime_temp = None
|
||||
return {
|
||||
"market_temperature": {
|
||||
"market_temperature": realtime_temp or ({
|
||||
"trade_date": mt.trade_date if mt else "",
|
||||
"temperature": mt.temperature if mt else 0,
|
||||
"up_count": mt.up_count if mt else 0,
|
||||
@ -38,7 +45,7 @@ async def get_latest():
|
||||
"max_streak": mt.max_streak if mt else 0,
|
||||
"broken_rate": mt.broken_rate if mt else 0,
|
||||
"index_above_ma20": mt.index_above_ma20 if mt else False,
|
||||
} if mt else None,
|
||||
} if mt else None),
|
||||
"recommendations": [
|
||||
{
|
||||
"ts_code": r.ts_code,
|
||||
@ -81,6 +88,7 @@ async def get_latest():
|
||||
}
|
||||
for r in result.get("recommendations", [])
|
||||
],
|
||||
"market_anomalies": anomalies,
|
||||
"scan_mode": result.get("scan_mode", "unknown"),
|
||||
"strategy_profile": result.get("strategy_profile"),
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ router = APIRouter(prefix="/api/sectors", tags=["sectors"])
|
||||
|
||||
@router.get("/hot")
|
||||
async def get_hot_sectors(limit: int = 10):
|
||||
"""获取热门板块排名(盘中自动补充实时数据)"""
|
||||
"""获取今日主线主题排名(盘中自动补充实时数据并统一归一)"""
|
||||
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():
|
||||
@ -30,6 +30,10 @@ async def get_hot_sectors(limit: int = 10):
|
||||
{
|
||||
"sector_code": s.sector_code,
|
||||
"sector_name": s.sector_name,
|
||||
"board_type": s.board_type,
|
||||
"theme_id": s.theme_id,
|
||||
"theme_name": s.theme_name,
|
||||
"theme_aliases": s.theme_aliases,
|
||||
"pct_change": s.pct_change,
|
||||
"capital_inflow": s.capital_inflow,
|
||||
"limit_up_count": s.limit_up_count,
|
||||
@ -52,6 +56,7 @@ async def get_hot_sectors(limit: int = 10):
|
||||
"leading_stocks_realtime": s.leading_stocks_realtime,
|
||||
"is_realtime": s.is_realtime,
|
||||
"data_mode": s.data_mode,
|
||||
"source": s.source,
|
||||
}
|
||||
for s in sectors[:limit]
|
||||
]
|
||||
|
||||
Binary file not shown.
@ -43,6 +43,7 @@ async def get_sector_realtime_ranking(
|
||||
sort_by: str = "f3",
|
||||
descending: bool = True,
|
||||
page_size: int = 100,
|
||||
notify: bool = True,
|
||||
) -> list[dict]:
|
||||
"""获取东方财富板块实时涨跌幅排名
|
||||
|
||||
@ -79,7 +80,9 @@ async def get_sector_realtime_ranking(
|
||||
params = {
|
||||
"pn": "1",
|
||||
"pz": str(page_size),
|
||||
"po": "0" if descending else "1",
|
||||
# 东方财富列表接口里,po=1 对应降序,po=0 对应升序。
|
||||
# 之前这里写反后,会把“涨幅榜”实际拿成跌幅靠前列表。
|
||||
"po": "1" if descending else "0",
|
||||
"np": "1",
|
||||
"ut": "b1f8f8f8",
|
||||
"fltt": "2",
|
||||
@ -90,6 +93,7 @@ async def get_sector_realtime_ranking(
|
||||
}
|
||||
|
||||
try:
|
||||
board_type = "industry" if fs == "m:90+t:2" else "concept" if fs == "m:90+t:3" else "region" if fs == "m:90+t:1" else "unknown"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
SECTOR_LIST_URL,
|
||||
@ -114,6 +118,7 @@ async def get_sector_realtime_ranking(
|
||||
result.append({
|
||||
"sector_code": item.get("f12", ""),
|
||||
"sector_name": item.get("f14", ""),
|
||||
"board_type": board_type,
|
||||
"pct_change": float(pct),
|
||||
"amount": float(item.get("f6", 0) or 0),
|
||||
"turnover_rate": float(item.get("f8", 0) or 0),
|
||||
@ -132,11 +137,12 @@ async def get_sector_realtime_ranking(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"东方财富板块实时排名获取失败: {e}")
|
||||
await log_error(
|
||||
"eastmoney",
|
||||
f"东方财富板块实时排名获取失败: {e}",
|
||||
detail=f"fs={fs}, sort_by={sort_by}, page_size={page_size}",
|
||||
)
|
||||
if notify:
|
||||
await log_error(
|
||||
"eastmoney",
|
||||
f"东方财富板块实时排名获取失败: {e}",
|
||||
detail=f"fs={fs}, sort_by={sort_by}, page_size={page_size}",
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
@ -151,50 +157,68 @@ async def get_a_share_realtime_ranking(
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
params = {
|
||||
"pn": "1",
|
||||
"pz": str(page_size),
|
||||
"po": "0" if descending else "1",
|
||||
"np": "1",
|
||||
"ut": "b1f8f8f8",
|
||||
"fltt": "2",
|
||||
"invt": "2",
|
||||
"fid": sort_by,
|
||||
"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",
|
||||
}
|
||||
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"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
SECTOR_LIST_URL,
|
||||
params=params,
|
||||
headers=SECTOR_HEADERS,
|
||||
timeout=12,
|
||||
follow_redirects=True,
|
||||
)
|
||||
data = _parse_eastmoney_json(resp, "A股实时行情")
|
||||
|
||||
items = data.get("data", {}).get("diff", [])
|
||||
result = []
|
||||
for item in items:
|
||||
pct = item.get("f3")
|
||||
price = item.get("f2")
|
||||
if pct == "-" or price == "-" or pct is None or price is None:
|
||||
continue
|
||||
result.append({
|
||||
"ts_code": _eastmoney_code_to_ts(str(item.get("f12", ""))),
|
||||
"name": item.get("f14", ""),
|
||||
"price": float(price or 0),
|
||||
"pct_chg": float(pct or 0),
|
||||
"amount": float(item.get("f6", 0) or 0),
|
||||
"turnover_rate": float(item.get("f8", 0) or 0),
|
||||
"pe": _safe_float(item.get("f9")),
|
||||
"pb": _safe_float(item.get("f23")),
|
||||
"total_mv": _safe_float(item.get("f20")),
|
||||
"circ_mv": _safe_float(item.get("f21")),
|
||||
"main_net_inflow": _safe_float(item.get("f62")) or 0,
|
||||
})
|
||||
page = 1
|
||||
page_size_per_request = min(max(page_size, 200), 500)
|
||||
async with httpx.AsyncClient() as client:
|
||||
while len(result) < page_size:
|
||||
params = {
|
||||
"pn": str(page),
|
||||
"pz": str(page_size_per_request),
|
||||
"po": "1" if descending else "0",
|
||||
"np": "1",
|
||||
"ut": "b1f8f8f8",
|
||||
"fltt": "2",
|
||||
"invt": "2",
|
||||
"fid": sort_by,
|
||||
"fs": fs,
|
||||
"fields": fields,
|
||||
}
|
||||
resp = await client.get(
|
||||
SECTOR_LIST_URL,
|
||||
params=params,
|
||||
headers=SECTOR_HEADERS,
|
||||
timeout=12,
|
||||
follow_redirects=True,
|
||||
)
|
||||
data = _parse_eastmoney_json(resp, f"A股实时行情 第{page}页")
|
||||
items = data.get("data", {}).get("diff", [])
|
||||
if not items:
|
||||
break
|
||||
|
||||
before_count = len(result)
|
||||
for item in items:
|
||||
pct = item.get("f3")
|
||||
price = item.get("f2")
|
||||
code = str(item.get("f12", "") or "")
|
||||
if not code or pct == "-" or price == "-" or pct is None or price is None:
|
||||
continue
|
||||
result.append({
|
||||
"ts_code": _eastmoney_code_to_ts(code),
|
||||
"name": item.get("f14", ""),
|
||||
"price": float(price or 0),
|
||||
"pct_chg": float(pct or 0),
|
||||
"amount": float(item.get("f6", 0) or 0),
|
||||
"turnover_rate": float(item.get("f8", 0) or 0),
|
||||
"pe": _safe_float(item.get("f9")),
|
||||
"pb": _safe_float(item.get("f23")),
|
||||
"total_mv": _safe_float(item.get("f20")),
|
||||
"circ_mv": _safe_float(item.get("f21")),
|
||||
"main_net_inflow": _safe_float(item.get("f62")) or 0,
|
||||
})
|
||||
if len(result) == before_count or len(items) < page_size_per_request:
|
||||
break
|
||||
page += 1
|
||||
|
||||
# 去重,避免跨页或接口异常重复
|
||||
deduped = {}
|
||||
for item in result:
|
||||
deduped[item["ts_code"]] = item
|
||||
result = list(deduped.values())[:page_size]
|
||||
|
||||
ttl = 60 if _is_trading_hours() else 300
|
||||
cache.set(cache_key, result, ttl)
|
||||
@ -301,11 +325,21 @@ async def get_min_kline(
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"东方财富分钟K线获取失败 {ts_code}: {e}")
|
||||
logger.warning(f"东方财富分钟K线获取失败 {ts_code}: {e},尝试新浪兜底")
|
||||
try:
|
||||
from app.data.sina_client import get_min_kline as get_sina_min_kline
|
||||
fallback_df = await get_sina_min_kline(ts_code, period=period, count=count)
|
||||
if not fallback_df.empty:
|
||||
logger.info("新浪分钟K线兜底成功 %s: %s 条", ts_code, len(fallback_df))
|
||||
return fallback_df
|
||||
except Exception as fallback_error:
|
||||
logger.warning("新浪分钟K线兜底异常 %s: %s", ts_code, fallback_error)
|
||||
|
||||
logger.error(f"分钟K线双源获取失败 {ts_code}: 东方财富={e}")
|
||||
await log_error(
|
||||
"eastmoney",
|
||||
f"东方财富分钟K线获取失败 {ts_code}: {e}",
|
||||
detail=f"period={period}, count={count}",
|
||||
"market_data",
|
||||
f"分钟K线双源获取失败 {ts_code}: {e}",
|
||||
detail=f"primary=eastmoney, fallback=sina, period={period}, count={count}",
|
||||
)
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
132
backend/app/data/market_breadth_client.py
Normal file
132
backend/app/data/market_breadth_client.py
Normal file
@ -0,0 +1,132 @@
|
||||
"""市场广度客户端。
|
||||
|
||||
职责:
|
||||
1. 上涨/下跌/平盘家数:优先用东方财富全市场分页行情聚合
|
||||
2. 涨停/跌停家数:优先用东方财富专门池接口
|
||||
3. 接口不可用时回退到实时行情阈值估算
|
||||
|
||||
该模块只负责“市场广度”数据,不掺杂温度分数计算。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import today_trade_date
|
||||
from app.data.cache import cache
|
||||
from app.data.eastmoney_client import SECTOR_HEADERS, SECTOR_LIST_URL, _parse_eastmoney_json, get_a_share_realtime_ranking
|
||||
from app.data.models import MarketBreadth
|
||||
from app.db.error_logger import log_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ZT_POOL_URL = "https://push2ex.eastmoney.com/getTopicZTPool"
|
||||
DT_POOL_URL = "https://push2ex.eastmoney.com/getTopicDTPool"
|
||||
|
||||
|
||||
async def get_market_breadth() -> MarketBreadth:
|
||||
"""获取市场广度快照。"""
|
||||
cache_key = f"market_breadth:{today_trade_date()}"
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
quotes = await get_a_share_realtime_ranking(page_size=6000)
|
||||
if quotes and len(quotes) >= 3000:
|
||||
up_count = sum(1 for q in quotes if q.get("pct_chg", 0) > 0)
|
||||
down_count = sum(1 for q in quotes if q.get("pct_chg", 0) < 0)
|
||||
flat_count = sum(1 for q in quotes if q.get("pct_chg", 0) == 0)
|
||||
limit_up_count, limit_down_count, limit_source = await _get_limit_counts(quotes)
|
||||
breadth = MarketBreadth(
|
||||
trade_date=today_trade_date(),
|
||||
up_count=up_count,
|
||||
down_count=down_count,
|
||||
flat_count=flat_count,
|
||||
limit_up_count=limit_up_count,
|
||||
limit_down_count=limit_down_count,
|
||||
total_count=len(quotes),
|
||||
sample_count=len(quotes),
|
||||
source=f"eastmoney_quotes+{limit_source}",
|
||||
reliable=True,
|
||||
limit_counts_reliable=(limit_source == "eastmoney_pool"),
|
||||
)
|
||||
cache.set(cache_key, breadth, ttl=60)
|
||||
return breadth
|
||||
|
||||
logger.warning("市场广度实时样本不足,quotes=%s", len(quotes))
|
||||
breadth = MarketBreadth(
|
||||
trade_date=today_trade_date(),
|
||||
total_count=len(quotes),
|
||||
sample_count=len(quotes),
|
||||
source="snapshot",
|
||||
reliable=False,
|
||||
limit_counts_reliable=False,
|
||||
)
|
||||
cache.set(cache_key, breadth, ttl=30)
|
||||
return breadth
|
||||
|
||||
|
||||
async def _get_limit_counts(quotes: list[dict]) -> tuple[int, int, str]:
|
||||
"""优先走专门池接口,失败时回退到实时行情阈值估算。"""
|
||||
pool = await _get_limit_counts_from_pool()
|
||||
if pool is not None:
|
||||
return pool[0], pool[1], "eastmoney_pool"
|
||||
|
||||
limit_up_count = sum(1 for q in quotes if q.get("pct_chg", 0) >= _limit_threshold(q.get("ts_code", "")))
|
||||
limit_down_count = sum(1 for q in quotes if q.get("pct_chg", 0) <= -_limit_threshold(q.get("ts_code", "")))
|
||||
return limit_up_count, limit_down_count, "eastmoney_quote_estimate"
|
||||
|
||||
|
||||
async def _get_limit_counts_from_pool() -> tuple[int, int] | None:
|
||||
cache_key = f"market_limit_pool:{today_trade_date()}"
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
params = {
|
||||
"ut": "7eea3edcaed734bea9cbfc24409ed989",
|
||||
"dpt": "wz.ztzt",
|
||||
"Pageindex": "0",
|
||||
"pagesize": "1000",
|
||||
"date": today_trade_date(),
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||
zt_resp = await client.get(ZT_POOL_URL, params=params, headers=SECTOR_HEADERS)
|
||||
zt_data = _parse_eastmoney_json(zt_resp, "涨停池")
|
||||
dt_resp = await client.get(DT_POOL_URL, params=params, headers=SECTOR_HEADERS)
|
||||
dt_data = _parse_eastmoney_json(dt_resp, "跌停池")
|
||||
|
||||
zt_items = _extract_pool_items(zt_data)
|
||||
dt_items = _extract_pool_items(dt_data)
|
||||
result = (len(zt_items), len(dt_items))
|
||||
cache.set(cache_key, result, ttl=60)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning("东方财富涨跌停池获取失败: %s", e)
|
||||
await log_error(
|
||||
"market_breadth",
|
||||
f"东方财富涨跌停池获取失败: {e}",
|
||||
detail=f"trade_date={today_trade_date()}",
|
||||
notify=False,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_pool_items(data: dict) -> list[dict]:
|
||||
payload = data.get("data") or {}
|
||||
if isinstance(payload, dict):
|
||||
if isinstance(payload.get("pool"), list):
|
||||
return payload["pool"]
|
||||
if isinstance(payload.get("diff"), list):
|
||||
return payload["diff"]
|
||||
return []
|
||||
|
||||
|
||||
def _limit_threshold(ts_code: str) -> float:
|
||||
code = ts_code.split(".")[0] if ts_code else ""
|
||||
if code.startswith(("300", "301", "688")):
|
||||
return 19.8
|
||||
return 9.8
|
||||
@ -56,6 +56,10 @@ class CapitalFlow(BaseModel):
|
||||
class SectorInfo(BaseModel):
|
||||
sector_code: str
|
||||
sector_name: str
|
||||
board_type: str = "snapshot" # industry / concept / snapshot
|
||||
theme_id: str = ""
|
||||
theme_name: str = ""
|
||||
theme_aliases: list[str] = []
|
||||
trade_date: str = ""
|
||||
pct_change: float = 0 # 涨跌幅 %
|
||||
capital_inflow: float = 0 # 主力净流入(万元,原始数据来自Tushare亿元×10000)
|
||||
@ -80,6 +84,7 @@ class SectorInfo(BaseModel):
|
||||
leading_stocks_realtime: list[dict] = []
|
||||
is_realtime: bool = False
|
||||
data_mode: str = "daily_snapshot"
|
||||
source: str = "snapshot"
|
||||
|
||||
|
||||
class MarketTemperature(BaseModel):
|
||||
@ -94,6 +99,20 @@ class MarketTemperature(BaseModel):
|
||||
temperature: float = 0 # 综合温度 0-100
|
||||
|
||||
|
||||
class MarketBreadth(BaseModel):
|
||||
trade_date: str
|
||||
up_count: int = 0
|
||||
down_count: int = 0
|
||||
flat_count: int = 0
|
||||
limit_up_count: int = 0
|
||||
limit_down_count: int = 0
|
||||
total_count: int = 0
|
||||
sample_count: int = 0
|
||||
source: str = "snapshot"
|
||||
reliable: bool = False
|
||||
limit_counts_reliable: bool = False
|
||||
|
||||
|
||||
class TechnicalSignal(BaseModel):
|
||||
ts_code: str
|
||||
name: str = ""
|
||||
|
||||
249
backend/app/data/sina_client.py
Normal file
249
backend/app/data/sina_client.py
Normal file
@ -0,0 +1,249 @@
|
||||
"""新浪财经数据客户端。
|
||||
|
||||
作为东方财富/腾讯的兜底数据源:
|
||||
- 分钟 K 线:用于盘中量能证据
|
||||
- 实时行情:可按股票列表批量拉取
|
||||
|
||||
新浪接口属于公开网页行情接口,字段稳定性弱于正式数据服务,因此只作为 fallback。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
import httpx
|
||||
import pandas as pd
|
||||
|
||||
from app.data.cache import cache
|
||||
from app.data.models import StockQuote
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SINA_KLINE_URL = "https://quotes.sina.cn/cn/api/json_v2.php/CN_MarketDataService.getKLineData"
|
||||
SINA_QUOTE_URL = "https://hq.sinajs.cn/list="
|
||||
HEADERS = {
|
||||
"Referer": "https://finance.sina.com.cn",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
}
|
||||
|
||||
|
||||
def _ts_code_to_sina(ts_code: str) -> str:
|
||||
code, market = ts_code.split(".")
|
||||
return f"{market.lower()}{code}"
|
||||
|
||||
|
||||
async def get_min_kline(ts_code: str, period: str = "5", count: int = 48) -> pd.DataFrame:
|
||||
"""获取新浪分钟 K 线,返回与东方财富分钟线一致的列。"""
|
||||
scale = period if period in {"5", "15", "30", "60"} else "5"
|
||||
cache_key = f"sina_min_kline:{ts_code}:{scale}:{count}"
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
params = {
|
||||
"symbol": _ts_code_to_sina(ts_code),
|
||||
"scale": scale,
|
||||
"ma": "no",
|
||||
"datalen": str(count),
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||
resp = await client.get(SINA_KLINE_URL, params=params, headers=HEADERS)
|
||||
resp.raise_for_status()
|
||||
|
||||
rows = _parse_kline_payload(resp.text)
|
||||
if not rows:
|
||||
return pd.DataFrame()
|
||||
|
||||
result = []
|
||||
for item in rows[-count:]:
|
||||
amount = item.get("amount")
|
||||
volume = item.get("volume", 0)
|
||||
result.append({
|
||||
"time": item.get("day", ""),
|
||||
"open": float(item.get("open", 0) or 0),
|
||||
"close": float(item.get("close", 0) or 0),
|
||||
"high": float(item.get("high", 0) or 0),
|
||||
"low": float(item.get("low", 0) or 0),
|
||||
"volume": float(volume or 0),
|
||||
# 新浪分钟线常见字段只有 volume;量能分布分析需要 amount 时用 volume 兜底。
|
||||
"amount": float(amount if amount not in (None, "") else volume or 0),
|
||||
})
|
||||
|
||||
df = pd.DataFrame(result)
|
||||
cache.set(cache_key, df, ttl=180)
|
||||
return df
|
||||
except Exception as e:
|
||||
logger.warning("新浪分钟K线获取失败 %s: %s", ts_code, e)
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
async def get_realtime_quotes_batch(ts_codes: list[str]) -> dict[str, StockQuote]:
|
||||
"""批量获取新浪实时行情。"""
|
||||
results: dict[str, StockQuote] = {}
|
||||
if not ts_codes:
|
||||
return results
|
||||
|
||||
batch_size = 80
|
||||
for i in range(0, len(ts_codes), batch_size):
|
||||
batch = ts_codes[i:i + batch_size]
|
||||
symbols = ",".join(_ts_code_to_sina(code) for code in batch)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||
resp = await client.get(f"{SINA_QUOTE_URL}{symbols}", headers=HEADERS)
|
||||
resp.raise_for_status()
|
||||
resp.encoding = "gbk"
|
||||
|
||||
quote_map = _parse_quote_payload(resp.text)
|
||||
for ts_code in batch:
|
||||
data = quote_map.get(_ts_code_to_sina(ts_code))
|
||||
quote = _quote_from_fields(ts_code, data)
|
||||
if quote:
|
||||
results[ts_code] = quote
|
||||
except Exception as e:
|
||||
logger.warning("新浪批量行情获取失败: %s", e)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def get_sector_realtime_ranking_by_industry(limit: int = 20) -> list[dict]:
|
||||
"""用新浪全市场实时行情 + Tushare 静态行业映射聚合今日行业榜。
|
||||
|
||||
这是东方财富板块榜不可用时的 fallback,不依赖 Tushare 当日行情。
|
||||
"""
|
||||
from app.data.tushare_client import tushare_client
|
||||
|
||||
cache_key = f"sina_sector_industry:{limit}"
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
stock_basic = tushare_client.get_stock_basic()
|
||||
if stock_basic.empty or "industry" not in stock_basic.columns:
|
||||
logger.warning("新浪行业榜兜底失败: 股票静态行业映射为空")
|
||||
return []
|
||||
|
||||
stock_basic = stock_basic.dropna(subset=["industry"])
|
||||
code_to_industry = {}
|
||||
code_to_name = {}
|
||||
for _, row in stock_basic.iterrows():
|
||||
ts_code = row.get("ts_code")
|
||||
industry = row.get("industry")
|
||||
if not ts_code or not industry:
|
||||
continue
|
||||
code_to_industry[ts_code] = str(industry)
|
||||
code_to_name[ts_code] = str(row.get("name") or ts_code)
|
||||
|
||||
quotes = await get_realtime_quotes_batch(list(code_to_industry.keys()))
|
||||
if not quotes:
|
||||
return []
|
||||
|
||||
grouped = defaultdict(lambda: {
|
||||
"amount": 0.0,
|
||||
"pct_sum": 0.0,
|
||||
"count": 0,
|
||||
"up_count": 0,
|
||||
"down_count": 0,
|
||||
"leading": None,
|
||||
})
|
||||
|
||||
for ts_code, quote in quotes.items():
|
||||
industry = code_to_industry.get(ts_code)
|
||||
if not industry:
|
||||
continue
|
||||
bucket = grouped[industry]
|
||||
bucket["amount"] += quote.amount or 0
|
||||
bucket["pct_sum"] += quote.pct_chg
|
||||
bucket["count"] += 1
|
||||
if quote.pct_chg > 0:
|
||||
bucket["up_count"] += 1
|
||||
elif quote.pct_chg < 0:
|
||||
bucket["down_count"] += 1
|
||||
leading = bucket["leading"]
|
||||
if leading is None or quote.pct_chg > leading["pct_chg"]:
|
||||
bucket["leading"] = {
|
||||
"ts_code": ts_code,
|
||||
"name": quote.name or code_to_name.get(ts_code, ts_code),
|
||||
"pct_chg": quote.pct_chg,
|
||||
}
|
||||
|
||||
result = []
|
||||
for idx, (industry, bucket) in enumerate(grouped.items(), start=1):
|
||||
count = bucket["count"] or 1
|
||||
leading = bucket["leading"] or {}
|
||||
result.append({
|
||||
"sector_code": f"SINA_{idx:03d}",
|
||||
"sector_name": industry,
|
||||
"board_type": "industry",
|
||||
"pct_change": round(bucket["pct_sum"] / count, 2),
|
||||
"amount": round(bucket["amount"], 2),
|
||||
"turnover_rate": 0,
|
||||
"up_count": int(bucket["up_count"]),
|
||||
"down_count": int(bucket["down_count"]),
|
||||
"leading_stock_name": leading.get("name", ""),
|
||||
"leading_stock_code": leading.get("ts_code", ""),
|
||||
"leading_stock_pct": float(leading.get("pct_chg", 0) or 0),
|
||||
"source": "sina",
|
||||
})
|
||||
|
||||
result.sort(key=lambda item: (item["pct_change"], item["amount"]), reverse=True)
|
||||
result = result[:limit]
|
||||
cache.set(cache_key, result, ttl=180)
|
||||
logger.info("新浪行业实时榜兜底: 获取 %s 个行业", len(result))
|
||||
return result
|
||||
|
||||
|
||||
def _parse_kline_payload(text: str) -> list[dict]:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
if text.startswith("["):
|
||||
return json.loads(text)
|
||||
start = text.find("[")
|
||||
end = text.rfind("]")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(text[start:end + 1])
|
||||
return []
|
||||
|
||||
|
||||
def _parse_quote_payload(text: str) -> dict[str, list[str]]:
|
||||
result: dict[str, list[str]] = {}
|
||||
pattern = re.compile(r'var hq_str_([^=]+)="([^"]*)";')
|
||||
for symbol, payload in pattern.findall(text or ""):
|
||||
if payload:
|
||||
result[symbol] = payload.split(",")
|
||||
return result
|
||||
|
||||
|
||||
def _quote_from_fields(ts_code: str, fields: list[str] | None) -> StockQuote | None:
|
||||
if not fields or len(fields) < 32:
|
||||
return None
|
||||
try:
|
||||
name = fields[0]
|
||||
open_price = float(fields[1] or 0)
|
||||
pre_close = float(fields[2] or 0)
|
||||
price = float(fields[3] or 0)
|
||||
high = float(fields[4] or 0)
|
||||
low = float(fields[5] or 0)
|
||||
volume = float(fields[8] or 0)
|
||||
amount = float(fields[9] or 0) / 10000
|
||||
pct_chg = ((price - pre_close) / pre_close * 100) if pre_close else 0
|
||||
if price <= 0:
|
||||
return None
|
||||
return StockQuote(
|
||||
ts_code=ts_code,
|
||||
name=name,
|
||||
price=price,
|
||||
pct_chg=round(pct_chg, 2),
|
||||
volume=volume,
|
||||
amount=amount,
|
||||
turnover_rate=0,
|
||||
high=high,
|
||||
low=low,
|
||||
open=open_price,
|
||||
pre_close=pre_close,
|
||||
)
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
Binary file not shown.
Binary file not shown.
@ -60,6 +60,10 @@ async def init_db():
|
||||
"ALTER TABLE recommendations ADD COLUMN prefilter_reason TEXT DEFAULT ''",
|
||||
"ALTER TABLE recommendations ADD COLUMN focus_points TEXT DEFAULT '[]'",
|
||||
"ALTER TABLE sector_heat ADD COLUMN stage TEXT",
|
||||
"ALTER TABLE sector_heat ADD COLUMN board_type TEXT DEFAULT 'theme'",
|
||||
"ALTER TABLE sector_heat ADD COLUMN theme_id TEXT DEFAULT ''",
|
||||
"ALTER TABLE sector_heat ADD COLUMN theme_name TEXT DEFAULT ''",
|
||||
"ALTER TABLE sector_heat ADD COLUMN theme_aliases TEXT DEFAULT '[]'",
|
||||
"ALTER TABLE sector_heat ADD COLUMN days_continuous INTEGER",
|
||||
"ALTER TABLE sector_heat ADD COLUMN member_count INTEGER",
|
||||
"ALTER TABLE sector_heat ADD COLUMN leading_stocks TEXT",
|
||||
|
||||
@ -53,6 +53,10 @@ sector_heat_table = Table(
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("sector_code", Text, nullable=False),
|
||||
Column("sector_name", Text, nullable=False),
|
||||
Column("board_type", Text, default="theme"),
|
||||
Column("theme_id", Text, default=""),
|
||||
Column("theme_name", Text, default=""),
|
||||
Column("theme_aliases", Text, default="[]"),
|
||||
Column("pct_change", Float),
|
||||
Column("capital_inflow", Float),
|
||||
Column("limit_up_count", Integer),
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -447,6 +447,47 @@ async def get_latest_recommendations() -> dict:
|
||||
return await _load_today_from_db()
|
||||
|
||||
|
||||
async def get_latest_market_anomalies(limit: int = 8) -> list[dict]:
|
||||
"""获取非主线市场异动观察,不混入主推荐池。"""
|
||||
try:
|
||||
async with get_db() as db:
|
||||
from sqlalchemy import text
|
||||
import json
|
||||
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT * FROM recommendations "
|
||||
"WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) "
|
||||
"AND COALESCE(recall_tags, '[]') NOT LIKE '%hot_theme_core%' "
|
||||
"AND COALESCE(recall_tags, '[]') NOT LIKE '%theme_leader%' "
|
||||
"AND COALESCE(recall_tags, '[]') NOT LIKE '%top_theme_member%' "
|
||||
"AND COALESCE(recall_tags, '[]') NOT LIKE '%sector_recall%' "
|
||||
"ORDER BY score DESC LIMIT :limit"
|
||||
),
|
||||
{"limit": limit},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
return [
|
||||
{
|
||||
"ts_code": r._mapping["ts_code"],
|
||||
"name": r._mapping["name"],
|
||||
"sector": r._mapping["sector"] or "",
|
||||
"score": r._mapping["score"] or 0,
|
||||
"action_plan": r._mapping["action_plan"] or "观察",
|
||||
"recall_tags": json.loads(r._mapping.get("recall_tags") or "[]"),
|
||||
"prefilter_decision": r._mapping.get("prefilter_decision") or "",
|
||||
"reason": r._mapping.get("prefilter_reason") or "",
|
||||
"created_at": str(r._mapping["created_at"]) if r._mapping["created_at"] else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"获取市场异动观察失败: {e}")
|
||||
from app.db.error_logger import log_error
|
||||
await log_error("recommender", f"获取市场异动观察失败: {e}", detail=traceback.format_exc())
|
||||
return []
|
||||
|
||||
|
||||
async def get_latest_sectors() -> list[SectorInfo]:
|
||||
"""获取最新的板块热度数据(从数据库读取,不触发扫描)"""
|
||||
return await _load_sectors_from_db()
|
||||
@ -481,6 +522,12 @@ async def get_recommendation_history(days: int = 7) -> list[dict]:
|
||||
") latest_t ON latest_t.recommendation_id = r.id "
|
||||
"WHERE r.created_at >= :start "
|
||||
"AND (r.action_plan IN ('可操作', '重点关注') OR COALESCE(r.llm_score, 0) >= 6 OR r.score >= 56) "
|
||||
"AND ("
|
||||
" COALESCE(r.recall_tags, '[]') LIKE '%hot_theme_core%' "
|
||||
" OR COALESCE(r.recall_tags, '[]') LIKE '%theme_leader%' "
|
||||
" OR COALESCE(r.recall_tags, '[]') LIKE '%top_theme_member%' "
|
||||
" OR COALESCE(r.recall_tags, '[]') LIKE '%sector_recall%'"
|
||||
") "
|
||||
"AND r.id IN ("
|
||||
" SELECT MAX(id) FROM recommendations "
|
||||
" WHERE created_at >= :start "
|
||||
@ -677,6 +724,10 @@ async def _save_to_db(result: dict):
|
||||
{
|
||||
"sector_code": sector.sector_code,
|
||||
"sector_name": sector.sector_name,
|
||||
"board_type": sector.board_type,
|
||||
"theme_id": sector.theme_id,
|
||||
"theme_name": sector.theme_name,
|
||||
"theme_aliases": json.dumps(sector.theme_aliases, ensure_ascii=False),
|
||||
"pct_change": sector.pct_change,
|
||||
"capital_inflow": sector.capital_inflow,
|
||||
"limit_up_count": sector.limit_up_count,
|
||||
@ -808,6 +859,12 @@ async def _load_today_from_db() -> dict:
|
||||
text("SELECT * FROM recommendations "
|
||||
"WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) "
|
||||
"AND (action_plan IN ('可操作', '重点关注') OR COALESCE(llm_score, 0) >= 6 OR score >= 56) "
|
||||
"AND ("
|
||||
" COALESCE(recall_tags, '[]') LIKE '%hot_theme_core%' "
|
||||
" OR COALESCE(recall_tags, '[]') LIKE '%theme_leader%' "
|
||||
" OR COALESCE(recall_tags, '[]') LIKE '%top_theme_member%' "
|
||||
" OR COALESCE(recall_tags, '[]') LIKE '%sector_recall%'"
|
||||
") "
|
||||
"AND id IN (SELECT MAX(id) FROM recommendations "
|
||||
" WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) "
|
||||
" GROUP BY ts_code) "
|
||||
@ -900,9 +957,14 @@ async def _load_sectors_from_db() -> list[SectorInfo]:
|
||||
# Parse JSON fields with fallback
|
||||
leading_stocks = json.loads(r.get("leading_stocks") or "[]")
|
||||
pct_trend = json.loads(r.get("pct_trend") or "[]")
|
||||
theme_aliases = json.loads(r.get("theme_aliases") or "[]")
|
||||
sectors.append(SectorInfo(
|
||||
sector_code=r["sector_code"],
|
||||
sector_name=r["sector_name"],
|
||||
board_type=r.get("board_type") or "snapshot",
|
||||
theme_id=r.get("theme_id") or "",
|
||||
theme_name=r.get("theme_name") or r["sector_name"],
|
||||
theme_aliases=theme_aliases,
|
||||
trade_date=r.get("trade_date") or "",
|
||||
pct_change=r["pct_change"] or 0,
|
||||
capital_inflow=r["capital_inflow"] or 0,
|
||||
@ -915,6 +977,7 @@ async def _load_sectors_from_db() -> list[SectorInfo]:
|
||||
pct_trend=pct_trend,
|
||||
turnover_avg=r.get("turnover_avg") or 0,
|
||||
main_force_ratio=r.get("main_force_ratio") or 0,
|
||||
source="snapshot",
|
||||
))
|
||||
return sectors
|
||||
except Exception as e:
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
"""趋势突破统一筛选器(自上而下方案,中短线交易定位)
|
||||
"""主题驱动的 A 股中短线筛选器。
|
||||
|
||||
三阶段管道:
|
||||
Step 1: 板块定位 — 找到有资金流入的热门板块 (3-5个)
|
||||
Step 2: 板块内选股 — 在热门板块成分股中筛出有资金流入的候选 (30-50只)
|
||||
Step 1: 主线定位 — 把实时板块/快照板块归一成系统 MarketTheme
|
||||
Step 2: 主题内选股 — 从主线主题成分、领涨股和实时异动中召回候选
|
||||
Step 3: 深度分析 — 供需 + 价格行为 + 趋势 + LLM (10-15只推荐)
|
||||
|
||||
评分公式:供需关系 50% + 价格行为 40% + 趋势 10%
|
||||
板块和资金流作为前置过滤条件,板块涨停数作为情绪奖励。
|
||||
主题地位和资金流作为前置上下文,涨停/广度只作为辅助证据。
|
||||
|
||||
风险乘数:惩罚取最大而非叠加(防过度惩罚),奖励可叠加。
|
||||
|
||||
@ -23,9 +23,11 @@ import traceback
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.analysis.market_temp import calculate_market_temperature
|
||||
from app.analysis.market_temp import build_realtime_market_temperature, calculate_market_temperature
|
||||
from app.analysis.sector_scanner import scan_hot_sectors
|
||||
from app.analysis.sector_realtime import get_today_realtime_sector_board
|
||||
from app.analysis.sector_alignment import build_hot_theme_membership, find_hot_theme_match
|
||||
from app.analysis.theme_mapper import merge_sectors_to_themes
|
||||
from app.analysis.trend_scanner import scan_trend_breakout
|
||||
from app.analysis.signals import generate_signals
|
||||
from app.analysis.intraday import (
|
||||
@ -42,6 +44,11 @@ from app.llm.strategy_selector import select_strategy_profile
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_main_theme_recommendation(rec: Recommendation) -> bool:
|
||||
tags = set(rec.recall_tags or [])
|
||||
return bool(tags & {"hot_theme_core", "theme_leader", "top_theme_member", "sector_recall"})
|
||||
|
||||
|
||||
async def run_screening(trade_date: str = None) -> dict:
|
||||
"""执行趋势突破筛选流程
|
||||
|
||||
@ -62,36 +69,40 @@ async def run_screening(trade_date: str = None) -> dict:
|
||||
market_temp = calculate_market_temperature(trade_date)
|
||||
|
||||
if intraday:
|
||||
market_temp = await intraday_market_temperature(market_temp)
|
||||
logger.info(f"盘中市场温度(实时调整): {market_temp.temperature}")
|
||||
market_temp, realtime_used = await build_realtime_market_temperature(market_temp)
|
||||
if realtime_used:
|
||||
logger.info(f"实时市场温度(统一广度口径): {market_temp.temperature}")
|
||||
else:
|
||||
market_temp = await intraday_market_temperature(market_temp)
|
||||
logger.info(f"盘中市场温度(兼容回退): {market_temp.temperature}")
|
||||
else:
|
||||
logger.info(f"市场温度: {market_temp.temperature}")
|
||||
|
||||
market_temp_score = market_temp.temperature
|
||||
|
||||
# ── Step 1: 板块定位 ──
|
||||
logger.info("=== Step 1: 板块定位 ===")
|
||||
all_sectors = await get_today_realtime_sector_board(limit=30) if intraday else []
|
||||
if not all_sectors:
|
||||
all_sectors = scan_hot_sectors(trade_date)
|
||||
# ── Step 1: 主线主题定位 ──
|
||||
logger.info("=== Step 1: 主线主题定位 ===")
|
||||
all_themes = await get_today_realtime_sector_board(limit=30) if intraday else []
|
||||
if not all_themes:
|
||||
all_themes = merge_sectors_to_themes(scan_hot_sectors(trade_date), limit=30)
|
||||
|
||||
# 前置过滤:只保留有资金流入 + 非末期的板块
|
||||
# 前置过滤:只保留有资金或实时强度支撑、且非尾声的主题
|
||||
hot_sectors = [
|
||||
s for s in all_sectors
|
||||
s for s in all_themes
|
||||
if (s.capital_inflow > 0 or s.is_realtime) and s.stage not in ("end",)
|
||||
][:settings.top_sector_count]
|
||||
|
||||
if not hot_sectors:
|
||||
logger.info("无合格热门板块(需要资金流入+非末期),回退到全部板块")
|
||||
hot_sectors = all_sectors[:settings.top_sector_count]
|
||||
logger.info("无合格主线主题(需要资金/实时强度+非尾声),回退到全部主题")
|
||||
hot_sectors = all_themes[:settings.top_sector_count]
|
||||
|
||||
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}")
|
||||
|
||||
# 如果板块来自 Tushare 快照,盘中/盘后用实时行情更新板块涨幅和广度
|
||||
# 如果主题来自 Tushare 快照,盘中用实时行情更新后再次归一到主题。
|
||||
if intraday and hot_sectors and not hot_sectors[0].is_realtime:
|
||||
hot_sectors = await intraday_sector_scan(hot_sectors)
|
||||
hot_sectors = merge_sectors_to_themes(await intraday_sector_scan(hot_sectors), limit=settings.top_sector_count)
|
||||
|
||||
strategy_profile = await select_strategy_profile(market_temp, hot_sectors, intraday)
|
||||
logger.info(
|
||||
@ -140,13 +151,19 @@ async def run_screening(trade_date: str = None) -> dict:
|
||||
recommendations = [
|
||||
r for r in recommendations
|
||||
if (
|
||||
r.action_plan in {"可操作", "重点关注"}
|
||||
or (r.llm_score is not None and r.llm_score >= 6)
|
||||
or r.score >= max(strategy_profile.min_score - 4, 56)
|
||||
_is_main_theme_recommendation(r)
|
||||
and (
|
||||
r.action_plan in {"可操作", "重点关注"}
|
||||
or (r.llm_score is not None and r.llm_score >= 6)
|
||||
or r.score >= max(strategy_profile.min_score - 4, 56)
|
||||
)
|
||||
)
|
||||
]
|
||||
else:
|
||||
recommendations = [r for r in recommendations if r.score >= strategy_profile.min_score]
|
||||
recommendations = [
|
||||
r for r in recommendations
|
||||
if _is_main_theme_recommendation(r) and r.score >= strategy_profile.min_score
|
||||
]
|
||||
|
||||
logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===")
|
||||
for r in recommendations[:5]:
|
||||
@ -168,7 +185,7 @@ async def _select_from_hot_sectors(
|
||||
trade_date: str,
|
||||
intraday: bool,
|
||||
) -> list[dict]:
|
||||
"""热点板块轻召回。
|
||||
"""主线主题轻召回。
|
||||
|
||||
这里只做基础清洗和活跃度排序,不再用“主力净流入必须为正”之类的硬门槛直接淘汰。
|
||||
"""
|
||||
@ -177,34 +194,13 @@ async def _select_from_hot_sectors(
|
||||
if not trade_date:
|
||||
trade_date = tushare_client.get_latest_trade_date()
|
||||
|
||||
sector_member_codes: set[str] = set()
|
||||
sector_code_map: dict[str, str] = {}
|
||||
sector_stage_map: dict[str, str] = {}
|
||||
sector_rank_map: dict[str, int] = {}
|
||||
leader_codes: set[str] = set()
|
||||
|
||||
for idx, s in enumerate(hot_sectors):
|
||||
sector_rank_map[s.sector_name] = idx + 1
|
||||
sector_stage_map[s.sector_name] = s.stage
|
||||
for leader in s.leading_stocks or []:
|
||||
leader_code = str(leader.get("ts_code", "")).strip()
|
||||
if leader_code:
|
||||
leader_codes.add(leader_code)
|
||||
try:
|
||||
members_df = tushare_client.get_ths_members(s.sector_code)
|
||||
if not members_df.empty and "con_code" in members_df.columns:
|
||||
codes = members_df["con_code"].tolist()
|
||||
sector_member_codes.update(codes)
|
||||
for c in codes:
|
||||
sector_code_map[c] = s.sector_name
|
||||
except Exception as e:
|
||||
logger.warning(f"获取板块 {s.sector_name} 成分股失败: {e}")
|
||||
sector_member_codes, sector_code_map, sector_stage_map, sector_rank_map, leader_codes = build_hot_theme_membership(hot_sectors)
|
||||
|
||||
if not sector_member_codes:
|
||||
logger.info("Step 2: 热点板块轻召回无成分股数据")
|
||||
logger.info("Step 2: 主线主题轻召回无成分股数据")
|
||||
return []
|
||||
|
||||
logger.info(f"Step 2: 热点板块共 {len(sector_member_codes)} 只成分股")
|
||||
logger.info(f"Step 2: 主线主题共 {len(sector_member_codes)} 只成分股")
|
||||
|
||||
stock_basic = tushare_client.get_stock_basic()
|
||||
exclude_codes = set()
|
||||
@ -238,7 +234,7 @@ async def _select_from_hot_sectors(
|
||||
].copy()
|
||||
|
||||
if filtered_basic.empty:
|
||||
logger.info("Step 2 热点板块轻召回严格过滤无结果,放宽换手率重试")
|
||||
logger.info("Step 2 主线主题轻召回严格过滤无结果,放宽换手率重试")
|
||||
filtered_basic = basic[
|
||||
(basic["ts_code"].isin(sector_member_codes)) &
|
||||
(~basic["ts_code"].isin(exclude_codes)) &
|
||||
@ -274,7 +270,11 @@ async def _select_from_hot_sectors(
|
||||
for _, base_row in filtered_basic.iterrows():
|
||||
ts_code = base_row["ts_code"]
|
||||
name = name_map.get(ts_code, ts_code)
|
||||
sector_name = sector_code_map.get(ts_code, industry_map.get(ts_code, ""))
|
||||
matched_sector = sector_code_map.get(ts_code, "")
|
||||
if not matched_sector:
|
||||
hot_match = find_hot_theme_match(industry_map.get(ts_code, ""), hot_sectors)
|
||||
matched_sector = hot_match.sector_name if hot_match else ""
|
||||
sector_name = matched_sector or industry_map.get(ts_code, "")
|
||||
mf_info = mf_lookup.get(ts_code, {})
|
||||
turnover_rate = float(base_row["turnover_rate"]) if pd.notna(base_row.get("turnover_rate")) else 0
|
||||
circ_mv = float(base_row["circ_mv"]) if pd.notna(base_row.get("circ_mv")) else 0
|
||||
@ -289,6 +289,8 @@ async def _select_from_hot_sectors(
|
||||
recall_score += 14
|
||||
elif sector_rank <= 5:
|
||||
recall_score += 8
|
||||
if sector_rank <= 5:
|
||||
recall_score += 12
|
||||
if ts_code in leader_codes:
|
||||
recall_score += 14
|
||||
if turnover_rate >= settings.min_turnover_rate:
|
||||
@ -300,13 +302,15 @@ async def _select_from_hot_sectors(
|
||||
elif main_net_inflow < 0:
|
||||
recall_score -= 4
|
||||
|
||||
recall_tags = ["hot_sector_core"]
|
||||
recall_tags = ["hot_theme_core"]
|
||||
if ts_code in leader_codes:
|
||||
recall_tags.append("sector_leader")
|
||||
recall_tags.append("theme_leader")
|
||||
if main_net_inflow > 0:
|
||||
recall_tags.append("moneyflow_support")
|
||||
if volume_ratio and volume_ratio >= 1.5:
|
||||
recall_tags.append("volume_active")
|
||||
if sector_rank <= 3:
|
||||
recall_tags.append("top_theme_member")
|
||||
candidates.append({
|
||||
"ts_code": ts_code,
|
||||
"name": name,
|
||||
@ -321,7 +325,7 @@ async def _select_from_hot_sectors(
|
||||
"inflow_ratio": inflow_ratio,
|
||||
"recall_score": round(recall_score, 1),
|
||||
"recall_tags": recall_tags,
|
||||
"stock_role_hint": "板块领涨前排" if ts_code in leader_codes else "板块活跃成分",
|
||||
"stock_role_hint": "主题领涨前排" if ts_code in leader_codes else ("主线主题成分" if sector_rank <= 3 else "主题活跃成分"),
|
||||
})
|
||||
|
||||
candidates.sort(key=lambda item: (
|
||||
@ -330,7 +334,7 @@ async def _select_from_hot_sectors(
|
||||
item.get("turnover_rate", 0),
|
||||
), reverse=True)
|
||||
top = candidates[: settings.candidate_pool_limit]
|
||||
logger.info(f"Step 2 热点板块轻召回: {len(top)} 只")
|
||||
logger.info(f"Step 2 主线主题轻召回: {len(top)} 只")
|
||||
return top
|
||||
|
||||
|
||||
@ -380,6 +384,7 @@ async def _build_candidate_pool(
|
||||
|
||||
candidates = list(merged.values())
|
||||
candidates.sort(key=lambda item: (
|
||||
1 if "sector_recall" in item.get("recall_tags", []) or "top_theme_member" in item.get("recall_tags", []) else 0,
|
||||
item.get("recall_score", 0),
|
||||
item.get("main_net_inflow", 0),
|
||||
item.get("turnover_rate", 0),
|
||||
@ -555,6 +560,8 @@ async def _build_recommendations(
|
||||
penalties.append(0.80)
|
||||
|
||||
sector_stage = _get_sector_stage(sector, hot_sectors)
|
||||
hot_theme_match = find_hot_theme_match(sector, hot_sectors)
|
||||
|
||||
if sector_stage == "end":
|
||||
penalties.append(0.70)
|
||||
elif sector_stage == "late":
|
||||
@ -577,6 +584,11 @@ async def _build_recommendations(
|
||||
if entry_signal.get("signal_score", 0) >= 80:
|
||||
final_score *= 1.10
|
||||
|
||||
if not hot_theme_match:
|
||||
final_score *= 0.82
|
||||
elif hot_theme_match not in hot_sectors[:5]:
|
||||
final_score *= 0.9
|
||||
|
||||
signal_matches_profile = bool(signal_priority and signal_name in signal_priority[:4])
|
||||
if signal_type != EntrySignal.NONE and signal_priority:
|
||||
priority_rank = signal_priority.index(signal_type.value)
|
||||
@ -738,6 +750,9 @@ async def _build_recommendations(
|
||||
),
|
||||
"recall_tags": stock.get("recall_tags", []),
|
||||
"sector_stage": sector_stage,
|
||||
"hot_theme_matched": bool(hot_theme_match),
|
||||
"hot_theme_name": hot_theme_match.sector_name if hot_theme_match else "",
|
||||
"hot_theme_aliases": hot_theme_match.theme_aliases if hot_theme_match else [],
|
||||
"stock_role_hint": stock.get("stock_role_hint", "待判断"),
|
||||
"entry_signal_type": signal_name,
|
||||
"entry_signal_score": round(entry_signal.get("signal_score", 0), 1),
|
||||
@ -785,7 +800,13 @@ async def _build_recommendations(
|
||||
market_summary = (
|
||||
f"市场温度: {market_temp.temperature}/100, "
|
||||
f"涨跌比: {market_temp.up_count}涨/{market_temp.down_count}跌, "
|
||||
f"涨停: {market_temp.limit_up_count}家"
|
||||
f"涨停: {market_temp.limit_up_count}家; "
|
||||
f"今日主线主题: "
|
||||
+ " / ".join(
|
||||
f"{s.sector_name}[{' / '.join((s.theme_aliases or [])[:3])}]"
|
||||
f"({(s.realtime_pct_change if s.realtime_pct_change is not None else s.pct_change):+.2f}%)"
|
||||
for s in hot_sectors[:5]
|
||||
)
|
||||
)
|
||||
|
||||
llm_candidates.sort(key=lambda c: c["quant_score"], reverse=True)
|
||||
@ -1329,7 +1350,7 @@ def _generate_reasons(
|
||||
# 板块
|
||||
sector = stock.get("sector", "")
|
||||
if sector:
|
||||
reasons.append(f"所属热门板块【{sector}】")
|
||||
reasons.append(f"所属主线主题【{sector}】")
|
||||
|
||||
return reasons[:3]
|
||||
|
||||
|
||||
@ -22,12 +22,14 @@ async def prefilter_single_stock(candidate: dict, market_summary: str) -> dict:
|
||||
|
||||
stock_text = f"""\
|
||||
股票: {candidate['name']}({candidate['ts_code']})
|
||||
板块: {candidate.get('sector', '未知')}
|
||||
主题: {candidate.get('sector', '未知')}
|
||||
主线主题匹配: {"是,匹配 " + candidate.get("hot_theme_name", "") if candidate.get("hot_theme_matched") else "否"}
|
||||
主题别名: {", ".join(candidate.get("hot_theme_aliases", []) or ["无"])}
|
||||
召回来源: {', '.join(candidate.get('recall_tags', []) or ['未标注'])}
|
||||
规则参考分: {candidate.get('quant_score', 0)}/100
|
||||
位置安全: {candidate.get('position_score', 50)}/100
|
||||
当前价: {candidate.get('current_price', '未知')}
|
||||
板块阶段: {candidate.get('sector_stage', '未知')}
|
||||
主题阶段: {candidate.get('sector_stage', '未知')}
|
||||
个股角色线索: {candidate.get('stock_role_hint', '待判断')}"""
|
||||
|
||||
if candidate.get("kline_summary"):
|
||||
@ -104,7 +106,8 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict:
|
||||
# 构建 prompt — 不传 signal_type,让 LLM 独立判断
|
||||
stock_text = f"""\
|
||||
股票: {candidate['name']}({candidate['ts_code']})
|
||||
板块: {candidate.get('sector', '未知')}
|
||||
主题: {candidate.get('sector', '未知')}
|
||||
主线主题匹配: {"是,匹配 " + candidate.get("hot_theme_name", "") if candidate.get("hot_theme_matched") else "否"}
|
||||
规则参考分: {candidate.get('quant_score', 0)}/100
|
||||
位置安全: {candidate.get('position_score', 50)}/100
|
||||
当前价: {candidate.get('current_price', '未知')}"""
|
||||
@ -127,6 +130,7 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict:
|
||||
"content": (
|
||||
"你是一位A股短线交易裁决员。"
|
||||
"你的任务是决定这只股票今天是否该进入推荐前列,以及应该归入可操作、重点关注还是观察。"
|
||||
"若候选股没有匹配今日主线主题,除非它是全市场极强异动且触发条件清晰,否则不要给可操作或重点关注。"
|
||||
"不要复述数据,不要写成长文,不要被规则参考分绑架。"
|
||||
"必须返回合法JSON。"
|
||||
),
|
||||
|
||||
Binary file not shown.
@ -1,5 +1,10 @@
|
||||
{
|
||||
"pages": {
|
||||
"/(auth)/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/layout.js"
|
||||
],
|
||||
"/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
@ -11,20 +16,15 @@
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/dashboard/page.js"
|
||||
],
|
||||
"/(auth)/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/layout.js"
|
||||
],
|
||||
"/(auth)/recommendations/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/recommendations/page.js"
|
||||
],
|
||||
"/(public)/layout": [
|
||||
"/(auth)/stock/[code]/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(public)/layout.js"
|
||||
"static/chunks/app/(auth)/stock/[code]/page.js"
|
||||
],
|
||||
"/(auth)/strategy/page": [
|
||||
"static/chunks/webpack.js",
|
||||
@ -36,16 +36,6 @@
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/sectors/page.js"
|
||||
],
|
||||
"/(auth)/diagnose/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/diagnose/page.js"
|
||||
],
|
||||
"/(auth)/stock/[code]/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/stock/[code]/page.js"
|
||||
],
|
||||
"/(auth)/settings/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
@ -56,20 +46,10 @@
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/watchlists/page.js"
|
||||
],
|
||||
"/(public)/login/page": [
|
||||
"/_not-found/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(public)/login/page.js"
|
||||
],
|
||||
"/(auth)/chat/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/chat/page.js"
|
||||
],
|
||||
"/(public)/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(public)/page.js"
|
||||
"static/chunks/app/_not-found/page.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
{
|
||||
"/_not-found/page": "app/_not-found/page.js",
|
||||
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
|
||||
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js",
|
||||
"/(auth)/settings/page": "app/(auth)/settings/page.js",
|
||||
"/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js",
|
||||
"/(auth)/chat/page": "app/(auth)/chat/page.js",
|
||||
"/(public)/login/page": "app/(public)/login/page.js",
|
||||
"/(public)/page": "app/(public)/page.js"
|
||||
"/(auth)/watchlists/page": "app/(auth)/watchlists/page.js"
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "5a77t1jXySke+j0Es8vduY/7S7yObSbYfKeh0OReITs="
|
||||
"encryptionKey": "qGqEEZzUFqZxeEgbiNEfbm7ophOEC3RaVTABPCm+KZ8="
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -1,13 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { fetchAPI, postAPI } from "@/lib/api";
|
||||
import type { LatestResult, SectorData, IndexOverview, OpsStatusResponse, StrategyBoard } from "@/lib/api";
|
||||
import type {
|
||||
IndexOverview,
|
||||
LatestResult,
|
||||
MarketTemperatureData,
|
||||
OpsStatusResponse,
|
||||
RecommendationData,
|
||||
SectorData,
|
||||
StrategyBoard,
|
||||
} from "@/lib/api";
|
||||
import MarketTemp from "@/components/market-temp";
|
||||
import SectorHeatmap from "@/components/sector-heatmap";
|
||||
import { useWebSocket } from "@/hooks/use-websocket";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useWebSocket } from "@/hooks/use-websocket";
|
||||
|
||||
interface ScanStatus {
|
||||
is_trading: boolean;
|
||||
@ -15,11 +23,12 @@ interface ScanStatus {
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SCAN_TIMEOUT_MS = 120_000; // 扫描超时:120秒后自动刷新数据
|
||||
const SCAN_TIMEOUT_MS = 120_000;
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
const [data, setData] = useState<LatestResult | null>(null);
|
||||
const [marketTemperature, setMarketTemperature] = useState<MarketTemperatureData | null>(null);
|
||||
const [sectors, setSectors] = useState<SectorData[]>([]);
|
||||
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -41,20 +50,22 @@ export default function DashboardPage() {
|
||||
fetchAPI<StrategyBoard>("/api/market/strategy-board").catch(() => null),
|
||||
fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null),
|
||||
]);
|
||||
const realtimeTemp = await fetchAPI<MarketTemperatureData>("/api/market/temperature").catch(() => latest.market_temperature);
|
||||
|
||||
setData(latest);
|
||||
setMarketTemperature(realtimeTemp ?? latest.market_temperature ?? null);
|
||||
setSectors(sectorData);
|
||||
setScanStatus(status);
|
||||
setIndices(overview);
|
||||
setStrategyBoard(board);
|
||||
setOpsStatus(ops);
|
||||
} catch (e) {
|
||||
console.error("加载数据失败:", e);
|
||||
} catch (error) {
|
||||
console.error("加载数据失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 清除扫描超时计时器
|
||||
const clearScanTimeout = useCallback(() => {
|
||||
if (scanTimeoutRef.current) {
|
||||
clearTimeout(scanTimeoutRef.current);
|
||||
@ -70,9 +81,7 @@ export default function DashboardPage() {
|
||||
useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => {
|
||||
clearScanTimeout();
|
||||
if (msg.type === "scan_update") {
|
||||
const modeLabel = msg.scan_mode === "realtime_today" || msg.scan_mode === "intraday"
|
||||
? "今日实时"
|
||||
: "历史收盘";
|
||||
const modeLabel = msg.scan_mode === "realtime_today" || msg.scan_mode === "intraday" ? "今日实时" : "历史收盘";
|
||||
setRefreshResult(`${modeLabel}扫描完成,发现 ${msg.count ?? 0} 只股票`);
|
||||
setRefreshing(false);
|
||||
loadData();
|
||||
@ -82,10 +91,9 @@ export default function DashboardPage() {
|
||||
setRefreshing(false);
|
||||
setTimeout(() => setRefreshResult(null), 5000);
|
||||
} else {
|
||||
// 其他消息类型(如 llm_analysis_ready),刷新数据
|
||||
loadData();
|
||||
}
|
||||
}, [loadData, clearScanTimeout]),
|
||||
}, [clearScanTimeout, loadData]),
|
||||
["scan_update", "scan_error", "llm_analysis_ready", "sector_scan_ready", "scan_complete"]
|
||||
);
|
||||
|
||||
@ -101,21 +109,18 @@ export default function DashboardPage() {
|
||||
|
||||
if (res.status === "already_running") {
|
||||
setRefreshResult(res.message || "扫描正在执行中,请稍候");
|
||||
// 保持 refreshing,等待 WS 推送完成
|
||||
} else if (res.status === "scanning") {
|
||||
setRefreshResult("扫描已启动,完成后自动刷新...");
|
||||
// 保持 refreshing,等待 WS 推送
|
||||
}
|
||||
|
||||
// 设置超时:如果 120 秒内没收到 WebSocket 消息,自动停止加载状态并刷新数据
|
||||
scanTimeoutRef.current = setTimeout(() => {
|
||||
setRefreshResult("扫描超时,已自动刷新数据");
|
||||
setRefreshing(false);
|
||||
loadData();
|
||||
setTimeout(() => setRefreshResult(null), 5000);
|
||||
}, SCAN_TIMEOUT_MS);
|
||||
} catch (e) {
|
||||
console.error("触发扫描失败:", e);
|
||||
} catch (error) {
|
||||
console.error("触发扫描失败:", error);
|
||||
setRefreshResult("触发扫描失败,请重试");
|
||||
setRefreshing(false);
|
||||
setTimeout(() => setRefreshResult(null), 5000);
|
||||
@ -127,7 +132,7 @@ export default function DashboardPage() {
|
||||
setRefreshResult(null);
|
||||
try {
|
||||
if (action === "update_tracking") {
|
||||
const result = await postAPI<{ tracked?: number; win_rate?: number; avg_return?: number }>("/api/recommendations/update-tracking");
|
||||
const result = await postAPI<{ tracked?: number; win_rate?: number }>("/api/recommendations/update-tracking");
|
||||
setRefreshResult(`跟踪已更新,样本 ${result.tracked ?? 0},胜率 ${(result.win_rate ?? 0).toFixed(1)}%`);
|
||||
} else if (action === "generate_strategy_board") {
|
||||
await postAPI("/api/market/generate-strategy-board");
|
||||
@ -147,402 +152,552 @@ export default function DashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 清理超时计时器
|
||||
useEffect(() => {
|
||||
return () => clearScanTimeout();
|
||||
}, [clearScanTimeout]);
|
||||
|
||||
const recommendations = data?.recommendations ?? [];
|
||||
const actionable = recommendations.filter((rec) => rec.action_plan === "可操作");
|
||||
const watch = recommendations.filter((rec) => rec.action_plan === "重点关注");
|
||||
const observe = recommendations.filter((rec) => !["可操作", "重点关注"].includes(rec.action_plan ?? ""));
|
||||
|
||||
const marketSummary = useMemo(
|
||||
() => buildMarketSummary(marketTemperature, strategyBoard, scanStatus, actionable.length, watch.length),
|
||||
[actionable.length, marketTemperature, scanStatus, strategyBoard, watch.length]
|
||||
);
|
||||
|
||||
const todayActions = useMemo(
|
||||
() => buildActionGuides(strategyBoard, marketTemperature, actionable, watch, observe, sectors),
|
||||
[actionable, marketTemperature, observe, sectors, strategyBoard, watch]
|
||||
);
|
||||
|
||||
const focusQueue = actionable.length ? actionable : watch.length ? watch : recommendations;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 space-y-5">
|
||||
<div className="h-32 glass-card-static animate-shimmer" />
|
||||
<div className="h-48 glass-card-static animate-shimmer" />
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 space-y-4">
|
||||
<div className="h-28 glass-card-static animate-shimmer" />
|
||||
<div className="h-48 glass-card-static animate-shimmer" />
|
||||
<div className="h-64 glass-card-static animate-shimmer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-6">
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between animate-fade-in-up">
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||
<div className="flex items-start justify-between gap-4 animate-fade-in-up">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold tracking-tight">今日作战</h1>
|
||||
{scanStatus && (
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{scanStatus.is_trading ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-pulse" />
|
||||
<span className="text-emerald-400/80">交易中</span>
|
||||
<span className="text-text-muted/40">·</span>
|
||||
实时行情
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-text-muted/40 rounded-full" />
|
||||
已收盘
|
||||
<span className="text-text-muted/40">·</span>
|
||||
Tushare 日级数据
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-bold tracking-tight">今日作战</h1>
|
||||
{scanStatus?.is_trading ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] text-emerald-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
交易中
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
|
||||
已收盘
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
先看今天能不能做,再看该做什么,最后进入执行。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
{user?.role === "admin" && (
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="text-xs px-4 py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-40 transition-all duration-200 border border-amber-500/10 font-medium"
|
||||
>
|
||||
{refreshing ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
||||
分析中...
|
||||
</span>
|
||||
) : scanStatus?.is_trading ? (
|
||||
"盘中扫描"
|
||||
) : (
|
||||
"立即扫描"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{user?.role === "admin" ? (
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="rounded-xl border border-amber-500/15 bg-amber-500/10 px-4 py-2 text-xs font-medium text-amber-400 transition-colors hover:bg-amber-500/15 disabled:opacity-40"
|
||||
>
|
||||
{refreshing ? "扫描中..." : scanStatus?.is_trading ? "盘中扫描" : "立即扫描"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scan result toast */}
|
||||
{refreshResult && (
|
||||
<div className="glass-card-static border-amber-500/15 px-4 py-2.5 text-xs text-amber-400 animate-fade-in-up flex items-center gap-2">
|
||||
<span className="w-1 h-1 rounded-full bg-amber-400" />
|
||||
{refreshResult ? (
|
||||
<div className="glass-card-static animate-fade-in-up border-amber-500/15 px-4 py-2.5 text-xs text-amber-400">
|
||||
{refreshResult}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<MissionControl
|
||||
<DecisionHero
|
||||
board={strategyBoard}
|
||||
recommendations={data?.recommendations ?? []}
|
||||
sectors={sectors}
|
||||
strategyProfile={data?.strategy_profile ?? null}
|
||||
summary={marketSummary}
|
||||
actionableCount={actionable.length}
|
||||
watchCount={watch.length}
|
||||
observeCount={observe.length}
|
||||
/>
|
||||
|
||||
{opsStatus && (
|
||||
user?.role === "admin" ? (
|
||||
<OpsPanel
|
||||
opsStatus={opsStatus}
|
||||
isAdmin={true}
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
opsRunning={opsRunning}
|
||||
onAction={handleAdminAction}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
|
||||
<div className="animate-fade-in-up delay-100">
|
||||
<EvidenceHeader title="决策证据" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
|
||||
<MarketTemp data={data?.market_temperature ?? null} indices={indices} />
|
||||
<SectorHeatmap sectors={sectors} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.3fr)_minmax(280px,0.7fr)] gap-4 animate-fade-in-up">
|
||||
<ActionPanel
|
||||
actions={todayActions}
|
||||
focusQueue={focusQueue.slice(0, 4)}
|
||||
fallbackTitle={actionable.length ? "优先执行" : watch.length ? "重点观察" : "仅保留观察"}
|
||||
/>
|
||||
<ExecutionPanel
|
||||
recommendations={recommendations}
|
||||
actionableCount={actionable.length}
|
||||
watchCount={watch.length}
|
||||
observeCount={observe.length}
|
||||
isAdmin={user?.role === "admin"}
|
||||
opsStatus={opsStatus}
|
||||
refreshing={refreshing}
|
||||
opsRunning={opsRunning}
|
||||
onRefresh={handleRefresh}
|
||||
onAction={handleAdminAction}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-4 animate-fade-in-up">
|
||||
<MarketTemp data={marketTemperature ?? data?.market_temperature ?? null} indices={indices} />
|
||||
<SectorHeatmap sectors={sectors} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OpsPanel({
|
||||
opsStatus,
|
||||
function DecisionHero({
|
||||
board,
|
||||
summary,
|
||||
actionableCount,
|
||||
watchCount,
|
||||
observeCount,
|
||||
}: {
|
||||
board: StrategyBoard | null;
|
||||
summary: ReturnType<typeof buildMarketSummary>;
|
||||
actionableCount: number;
|
||||
watchCount: number;
|
||||
observeCount: number;
|
||||
}) {
|
||||
const isRealtime = board?.data_mode === "realtime_today";
|
||||
|
||||
return (
|
||||
<div className="glass-card-static animate-fade-in-up overflow-hidden p-4 md:p-5">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.2em] text-amber-400">今日结论</span>
|
||||
{isRealtime ? (
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] text-emerald-400">实时</span>
|
||||
) : null}
|
||||
</div>
|
||||
<h2 className="mt-2 text-xl font-bold tracking-tight text-text-primary">{summary.headline}</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary">
|
||||
{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={board?.recommended_mode ?? "等待更新"} />
|
||||
<HeroFact label="仓位" value={board?.position_suggestion ?? "等待更新"} />
|
||||
<HeroFact label="风险" value={board?.risk_level ?? "等待更新"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionPanel({
|
||||
actions,
|
||||
focusQueue,
|
||||
fallbackTitle,
|
||||
}: {
|
||||
actions: ReturnType<typeof buildActionGuides>;
|
||||
focusQueue: RecommendationData[];
|
||||
fallbackTitle: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="glass-card-static p-4 md:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">今天该做什么</h3>
|
||||
<p className="mt-1 text-xs text-text-muted">把市场结论拆成可执行动作,而不是只给情绪描述。</p>
|
||||
</div>
|
||||
<a href="/strategy" className="text-xs text-text-muted transition-colors hover:text-amber-400">
|
||||
系统校准
|
||||
</a>
|
||||
</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>
|
||||
|
||||
<div className="glass-card-static p-4 md:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{fallbackTitle}</h3>
|
||||
<p className="mt-1 text-xs text-text-muted">只展示最该盯的少量标的,避免首页无限拉长。</p>
|
||||
</div>
|
||||
<a href="/recommendations" className="text-xs text-text-muted transition-colors hover:text-amber-400">
|
||||
全部推荐
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{focusQueue.length ? (
|
||||
focusQueue.map((rec) => <FocusStockCard key={rec.ts_code} rec={rec} />)
|
||||
) : (
|
||||
<div className="col-span-full rounded-2xl border border-border-subtle bg-surface-1/70 p-6 text-center text-sm text-text-muted">
|
||||
暂无标的,等待新一轮扫描输出。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecutionPanel({
|
||||
recommendations,
|
||||
actionableCount,
|
||||
watchCount,
|
||||
observeCount,
|
||||
isAdmin,
|
||||
opsStatus,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
opsRunning,
|
||||
onRefresh,
|
||||
onAction,
|
||||
}: {
|
||||
opsStatus: OpsStatusResponse;
|
||||
isAdmin: boolean;
|
||||
recommendations: RecommendationData[];
|
||||
actionableCount: number;
|
||||
watchCount: number;
|
||||
observeCount: number;
|
||||
isAdmin?: boolean;
|
||||
opsStatus: OpsStatusResponse | null;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
opsRunning: string | null;
|
||||
onRefresh: () => void;
|
||||
onAction: (action: "update_tracking" | "generate_strategy_board" | "generate_strategy_iteration") => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-card-static p-4 animate-fade-in-up">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold">Admin Ops</div>
|
||||
<div className="text-xs text-text-muted mt-2">
|
||||
{opsStatus.data_freshness.message}
|
||||
<div className="space-y-4">
|
||||
<div className="glass-card-static p-4 md:p-5">
|
||||
<h3 className="text-sm font-semibold text-text-primary">执行入口</h3>
|
||||
<p className="mt-1 text-xs text-text-muted">不同任务进入不同页面,首页只保留关键入口和现状摘要。</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3">
|
||||
<NavCard
|
||||
href="/recommendations"
|
||||
title="推荐池"
|
||||
description={`当前 ${recommendations.length} 只候选,${actionableCount} 只可操作,${watchCount} 只重点关注。`}
|
||||
/>
|
||||
<NavCard
|
||||
href="/watchlist"
|
||||
title="自选股"
|
||||
description="查看你的自选股跟踪、AI 诊断与定时分析结果。"
|
||||
/>
|
||||
<NavCard
|
||||
href="/strategy"
|
||||
title="系统校准"
|
||||
description="看系统近期方法是否有效、哪里失效,以及下一轮该怎么调整。"
|
||||
/>
|
||||
<NavCard
|
||||
href="/chat"
|
||||
title="AI 对话"
|
||||
description="用于追问个股、板块和策略,不承担首页主决策职责。"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] font-semibold text-text-secondary">推荐池状态</span>
|
||||
<span className="text-[11px] text-text-muted">{recommendations.length} 只</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2 text-center">
|
||||
<MiniCount label="可操作" value={actionableCount} tone="text-red-400" />
|
||||
<MiniCount label="重点关注" value={watchCount} tone="text-amber-400" />
|
||||
<MiniCount label="观察" value={observeCount} tone="text-text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-text-muted">
|
||||
<span>市场 {opsStatus.data_freshness.market_trade_date || "暂无"}</span>
|
||||
<span>板块 {opsStatus.data_freshness.sector_trade_date || "暂无"}</span>
|
||||
<span>跟踪 {opsStatus.data_freshness.tracking_trade_date || "暂无"}</span>
|
||||
<span>{opsStatus.scan_running ? "扫描中" : "空闲"}</span>
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing || !!opsRunning}
|
||||
className="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 disabled:opacity-40 transition-all border border-amber-500/10"
|
||||
>
|
||||
{refreshing ? "扫描中..." : "立即扫描"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAction("update_tracking")}
|
||||
disabled={refreshing || !!opsRunning}
|
||||
className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{opsRunning === "update_tracking" ? "更新中..." : "更新跟踪"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAction("generate_strategy_board")}
|
||||
disabled={refreshing || !!opsRunning}
|
||||
className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{opsRunning === "generate_strategy_board" ? "生成中..." : "生成策略板"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAction("generate_strategy_iteration")}
|
||||
disabled={refreshing || !!opsRunning}
|
||||
className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{opsRunning === "generate_strategy_iteration" ? "生成中..." : "生成策略复盘"}
|
||||
</button>
|
||||
<a href="/strategy" className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors">
|
||||
查看策略复盘
|
||||
</a>
|
||||
<a href="/recommendations" className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-amber-400 transition-colors">
|
||||
查看推荐闭环
|
||||
</a>
|
||||
|
||||
{isAdmin && opsStatus ? (
|
||||
<div className="glass-card-static p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">管理员任务中心</h3>
|
||||
<p className="mt-1 text-xs text-text-muted">{opsStatus.data_freshness.message}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
|
||||
{opsStatus.scan_running ? "扫描中" : "空闲"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] text-text-muted">
|
||||
<FreshnessPill label="市场" value={opsStatus.data_freshness.market_trade_date || "暂无"} />
|
||||
<FreshnessPill label="板块" value={opsStatus.data_freshness.sector_trade_date || "暂无"} />
|
||||
<FreshnessPill label="跟踪" value={opsStatus.data_freshness.tracking_trade_date || "暂无"} />
|
||||
<FreshnessPill label="推荐" value={opsStatus.data_freshness.last_recommendation_created_at || "暂无"} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing || !!opsRunning}
|
||||
className="rounded-xl border border-amber-500/15 bg-amber-500/10 px-3 py-2 text-xs text-amber-400 disabled:opacity-40"
|
||||
>
|
||||
{refreshing ? "扫描中..." : "立即扫描"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAction("update_tracking")}
|
||||
disabled={refreshing || !!opsRunning}
|
||||
className="rounded-xl border border-border-subtle bg-surface-1 px-3 py-2 text-xs text-text-secondary disabled:opacity-40"
|
||||
>
|
||||
{opsRunning === "update_tracking" ? "更新中..." : "更新跟踪"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAction("generate_strategy_board")}
|
||||
disabled={refreshing || !!opsRunning}
|
||||
className="rounded-xl border border-border-subtle bg-surface-1 px-3 py-2 text-xs text-text-secondary disabled:opacity-40"
|
||||
>
|
||||
{opsRunning === "generate_strategy_board" ? "生成中..." : "生成策略板"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceHeader({ title }: { title: string }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MissionControl({
|
||||
board,
|
||||
recommendations,
|
||||
sectors,
|
||||
strategyProfile,
|
||||
function DecisionList({
|
||||
title,
|
||||
items,
|
||||
tone,
|
||||
}: {
|
||||
board: StrategyBoard | null;
|
||||
recommendations: LatestResult["recommendations"];
|
||||
sectors: SectorData[];
|
||||
strategyProfile: LatestResult["strategy_profile"];
|
||||
title: string;
|
||||
items: string[];
|
||||
tone: "positive" | "risk";
|
||||
}) {
|
||||
const actionable = recommendations.filter((rec) => rec.action_plan === "可操作");
|
||||
const watch = recommendations.filter((rec) => rec.action_plan === "重点关注");
|
||||
const observe = recommendations.filter((rec) => !["可操作", "重点关注"].includes(rec.action_plan ?? ""));
|
||||
const topSectors = sectors.slice(0, 3);
|
||||
const risks = board?.avoid_rules?.length ? board.avoid_rules : ["等待市场状态和推荐结果更新后生成风险约束。"];
|
||||
const primaryQueue = (actionable.length ? actionable : watch).slice(0, 3);
|
||||
const laneTitle = actionable.length ? "优先执行" : watch.length ? "候选观察" : "等待信号";
|
||||
const strategyName = strategyProfile?.name && strategyProfile.name !== "当前推荐策略"
|
||||
? strategyProfile.name
|
||||
: board?.recommended_mode ?? "待定";
|
||||
const strategyHint = strategyProfile?.description ?? "结合市场状态与主线强弱得出的出手方式";
|
||||
const isRealtimeBoard = board?.data_mode === "realtime_today";
|
||||
const topSectorEvidence = topSectors.map((sector) => ({
|
||||
key: sector.sector_code,
|
||||
name: sector.sector_name,
|
||||
pct: sector.realtime_pct_change ?? sector.pct_change,
|
||||
breadth: sector.realtime_up_count != null && sector.realtime_down_count != null
|
||||
? `${sector.realtime_up_count}/${sector.realtime_down_count}`
|
||||
: null,
|
||||
turnover: sector.realtime_turnover_rate,
|
||||
}));
|
||||
const dotClass = tone === "positive" ? "bg-emerald-400" : "bg-amber-400";
|
||||
|
||||
return (
|
||||
<div className="glass-card-static px-4 py-4 md:px-5 md:py-4 overflow-hidden relative animate-fade-in-up">
|
||||
<div className="absolute right-[-100px] top-[-120px] w-72 h-72 rounded-full bg-amber-500/[0.04] blur-3xl pointer-events-none" />
|
||||
<div className="relative grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_280px] gap-3">
|
||||
<div className="min-w-0 space-y-2.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">今日结论</span>
|
||||
{isRealtimeBoard && (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-emerald-500/15 bg-emerald-500/10 text-emerald-400">
|
||||
今日实时
|
||||
</span>
|
||||
)}
|
||||
{board?.generated_by === "rules+llm" && (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-cyan-500/15 bg-cyan-500/10 text-cyan-400">AI增强</span>
|
||||
)}
|
||||
</div>
|
||||
<a href="/recommendations" className="text-[11px] text-text-muted hover:text-amber-400 transition-colors">
|
||||
推荐池
|
||||
</a>
|
||||
<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 className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_200px] gap-2.5 items-start">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg md:text-[1.2rem] font-bold tracking-tight truncate">
|
||||
{board?.market_regime ?? "等待市场状态"}
|
||||
</h2>
|
||||
<p className="text-[12px] text-text-secondary leading-relaxed mt-1 max-w-3xl line-clamp-2">
|
||||
{board?.summary ?? "系统尚未生成今日作战结论。触发扫描后,将基于市场温度、板块主线、推荐生命周期和策略复盘生成操作框架。"}
|
||||
</p>
|
||||
{board?.trade_date && (
|
||||
<div className="text-[10px] text-text-muted mt-1">
|
||||
{isRealtimeBoard ? `分析日期 ${board.trade_date} · 今日实时优先` : `数据日期 ${board.trade_date}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 lg:grid-cols-1 gap-1.5">
|
||||
<CommandMetric label="可操作" value={actionable.length} description="盯盘" />
|
||||
<CommandMetric label="关注" value={watch.length} description="等确认" />
|
||||
<CommandMetric label="观察" value={observe.length} description="不追" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-1.5">
|
||||
<CompactDecision label="今日打法" value={strategyName} extra={strategyHint} />
|
||||
<CompactDecision label="仓位建议" value={board?.position_suggestion ?? "等待判断"} extra="今天建议的进攻上限" />
|
||||
<CompactDecision label="市场倾向" value={board?.action_bias ?? "等待确认"} extra={`风险 ${board?.risk_level ?? "-"}`} />
|
||||
<CompactDecision label="风险约束" value={risks[0] ?? "暂无"} extra={risks[1] ?? "等待更多市场证据"} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-surface-1/70 border border-border-subtle p-2.5">
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold mb-2">主线证据</div>
|
||||
{topSectorEvidence.length ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
{topSectorEvidence.map((sector) => (
|
||||
<div key={sector.key} className="rounded-lg bg-surface-2/70 border border-border-subtle px-2.5 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[12px] font-semibold text-text-primary truncate">{sector.name}</span>
|
||||
<span className={`text-[11px] font-mono tabular-nums ${sector.pct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||
{sector.pct > 0 ? "+" : ""}{sector.pct.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1 text-[10px] text-text-muted">
|
||||
{sector.breadth ? <span>涨跌 {sector.breadth}</span> : null}
|
||||
{sector.turnover != null ? <span>换手 {sector.turnover.toFixed(1)}%</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">暂无主线板块</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-surface-1/70 border border-border-subtle p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionTitle title={laneTitle} />
|
||||
<span className="text-[10px] text-text-muted">{primaryQueue.length}只</span>
|
||||
</div>
|
||||
<div className="mt-2.5 space-y-1.5">
|
||||
{primaryQueue.length ? (
|
||||
primaryQueue.map((rec) => (
|
||||
<CompactMissionStock key={`mission-${rec.ts_code}`} rec={rec} />
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-lg bg-surface-2/60 border border-border-subtle p-3 text-center">
|
||||
<div className="text-xs text-text-muted">暂无执行标的</div>
|
||||
<div className="text-[11px] text-text-muted/50 mt-0.5">等待扫描生成可操作或重点关注列表</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href="/strategy"
|
||||
className="inline-flex items-center justify-center mt-3 w-full rounded-xl border border-border-subtle bg-surface-2/60 px-3 py-2 text-[11px] text-text-muted hover:text-amber-400 hover:border-amber-500/20 transition-colors"
|
||||
>
|
||||
查看策略复盘
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactDecision({ label, value, extra }: { label: string; value: string; extra: string }) {
|
||||
function HeroMetric({ label, value, tone }: { label: string; value: number; tone: string }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-2 px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">{label}</div>
|
||||
<div className="text-sm font-semibold text-text-primary mt-0.5 line-clamp-1">{value}</div>
|
||||
{extra ? <div className="text-[10px] text-text-muted mt-0.5 line-clamp-2">{extra}</div> : null}
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3 text-center">
|
||||
<div className="text-[10px] uppercase tracking-wider text-text-muted">{label}</div>
|
||||
<div className={`mt-1 text-xl font-bold font-mono tabular-nums ${tone}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactMissionStock({ rec }: { rec: LatestResult["recommendations"][number] }) {
|
||||
const actionStyle: 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 aiConviction = rec.llm_score != null ? Math.round(rec.llm_score) : null;
|
||||
function HeroFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3">
|
||||
<div className="text-[10px] uppercase tracking-wider text-text-muted">{label}</div>
|
||||
<div className="mt-1 text-xs font-semibold leading-5 text-text-secondary line-clamp-2">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionBucket({
|
||||
title,
|
||||
items,
|
||||
tone,
|
||||
}: {
|
||||
title: string;
|
||||
items: string[];
|
||||
tone: "priority" | "watch" | "avoid";
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === "priority"
|
||||
? "bg-red-500/8 text-red-400"
|
||||
: tone === "watch"
|
||||
? "bg-amber-500/8 text-amber-400"
|
||||
: "bg-surface-2 text-text-muted";
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
||||
<div className={`inline-flex rounded-lg px-2 py-1 text-[10px] font-semibold uppercase tracking-wider ${toneClass}`}>
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div key={`${title}-${index}`} className="rounded-xl bg-surface-2/70 px-3 py-2 text-sm leading-6 text-text-secondary">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FocusStockCard({ rec }: { rec: RecommendationData }) {
|
||||
const badgeClass =
|
||||
rec.action_plan === "可操作"
|
||||
? "border-red-500/20 bg-red-500/10 text-red-400"
|
||||
: rec.action_plan === "重点关注"
|
||||
? "border-amber-500/20 bg-amber-500/10 text-amber-400"
|
||||
: "border-border-subtle bg-surface-2 text-text-muted";
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/stock/${rec.ts_code}`}
|
||||
className="block rounded-lg bg-surface-2/60 border border-border-subtle px-3 py-2 hover:border-amber-500/20 transition-colors"
|
||||
className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4 transition-colors hover:border-amber-500/20"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-sm font-semibold truncate">{rec.name}</span>
|
||||
<span className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-md border ${actionStyle[rec.action_plan ?? "观察"] ?? actionStyle["观察"]}`}>
|
||||
{rec.action_plan ?? "观察"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-text-muted font-mono tabular-nums mt-0.5 truncate">
|
||||
{rec.ts_code} · {rec.sector}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-text-primary">{rec.name}</span>
|
||||
<span className={`rounded-lg border px-2 py-0.5 text-[10px] ${badgeClass}`}>{rec.action_plan ?? "观察"}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-text-muted font-mono tabular-nums">{rec.ts_code} · {rec.sector}</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
{aiConviction != null ? (
|
||||
<>
|
||||
<div className="text-xs font-mono tabular-nums text-cyan-400/80">AI {aiConviction}/10</div>
|
||||
<div className="text-[10px] text-text-muted">{rec.action_plan ?? "观察"}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs text-text-secondary">{rec.action_plan ?? "观察"}</div>
|
||||
<div className="text-[10px] text-text-muted">参考分 {Math.round(rec.score)}</div>
|
||||
</>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<div className="text-xs font-mono tabular-nums text-text-secondary">{Math.round(rec.score)}</div>
|
||||
<div className="mt-0.5 text-[10px] text-text-muted">参考分</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[11px] text-text-secondary leading-relaxed line-clamp-1">
|
||||
{rec.trigger_condition ?? rec.entry_timing ?? rec.reasons?.[0] ?? "等待触发条件确认"}
|
||||
|
||||
<div className="mt-3 text-sm leading-6 text-text-secondary">
|
||||
{rec.trigger_condition ?? rec.entry_timing ?? rec.prefilter_reason ?? rec.reasons?.[0] ?? "等待新的触发条件。"}
|
||||
</div>
|
||||
|
||||
{(rec.invalidation_condition || rec.risk_note) ? (
|
||||
<div className="mt-3 rounded-xl bg-surface-2/70 px-3 py-2 text-[11px] leading-5 text-text-muted">
|
||||
风险: {rec.invalidation_condition ?? rec.risk_note}
|
||||
</div>
|
||||
) : null}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandMetric({ label, value, description }: { label: string; value: number; description: string }) {
|
||||
function NavCard({ href, title, description }: { href: string; title: string; description: string }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-1/70 border border-border-subtle px-2.5 py-2">
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">{label}</div>
|
||||
<div className="flex items-baseline justify-between gap-2 mt-0.5">
|
||||
<div className="text-lg font-bold font-mono tabular-nums text-text-primary">{value}</div>
|
||||
<div className="text-[10px] text-text-muted">{description}</div>
|
||||
</div>
|
||||
<a href={href} className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3 transition-colors hover:border-amber-500/20">
|
||||
<div className="text-sm font-semibold text-text-primary">{title}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-text-muted">{description}</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
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 SectionTitle({ title }: { title: string }) {
|
||||
function FreshnessPill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">
|
||||
{title}
|
||||
<div className="rounded-xl bg-surface-1/70 px-2.5 py-2">
|
||||
<div className="text-[10px] text-text-muted">{label}</div>
|
||||
<div className="mt-1 truncate text-[11px] text-text-secondary">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildMarketSummary(
|
||||
marketTemperature: MarketTemperatureData | null,
|
||||
board: StrategyBoard | null,
|
||||
scanStatus: ScanStatus | null,
|
||||
actionableCount: number,
|
||||
watchCount: number
|
||||
) {
|
||||
const temp = marketTemperature?.temperature ?? board?.metrics.temperature ?? 0;
|
||||
const headline =
|
||||
board?.market_regime ??
|
||||
(temp >= 70 ? "市场偏强,可以围绕主线进攻" :
|
||||
temp >= 50 ? "市场可做,但只做确认机会" :
|
||||
temp >= 30 ? "市场分化,轻仓试错" :
|
||||
"市场偏弱,以观察为主");
|
||||
|
||||
const detail =
|
||||
board?.summary ??
|
||||
(scanStatus?.is_trading
|
||||
? "当前使用盘中实时数据判断市场,重点在节奏、仓位和主线强弱,而不是静态分数。"
|
||||
: "当前以收盘后数据为主,适合复盘主线、更新候选池和准备下一交易日。");
|
||||
|
||||
const canDo = [
|
||||
actionableCount > 0 ? `优先只看 ${actionableCount} 只可操作标的,避免在杂波里分散注意力。` : "没有明确可操作标的时,只保留观察,不主动开新仓。",
|
||||
watchCount > 0 ? `重点关注 ${watchCount} 只等待确认的标的,等放量、回流或分歧转一致。` : "把注意力放在最强板块前排,而不是平均分配给所有候选。",
|
||||
board?.position_suggestion ?? (temp >= 50 ? "仓位可以试错,但不要脱离主线。" : "控制仓位,等待更强确认。"),
|
||||
];
|
||||
|
||||
const cannotDo = [
|
||||
board?.avoid_rules?.[0] ?? "不要追后排、跟风和没有板块支撑的个股。",
|
||||
board?.avoid_rules?.[1] ?? (temp < 50 ? "不要因为个别异动就误判成全面回暖。" : "不要把盘中脉冲当成全天主线。"),
|
||||
temp < 40 ? "不要扩大仓位做逆势试错。" : "不要脱离纪律随意切换题材。",
|
||||
];
|
||||
|
||||
return { headline, detail, canDo, cannotDo };
|
||||
}
|
||||
|
||||
function buildActionGuides(
|
||||
board: StrategyBoard | null,
|
||||
marketTemperature: MarketTemperatureData | null,
|
||||
actionable: RecommendationData[],
|
||||
watch: RecommendationData[],
|
||||
observe: RecommendationData[],
|
||||
sectors: SectorData[]
|
||||
) {
|
||||
const topSectors = (board?.watch_sectors?.slice(0, 2) ?? []).map((sector) => sector.sector_name);
|
||||
const backupSectors = sectors.slice(0, 2).map((sector) => sector.sector_name);
|
||||
const focusSectors = topSectors.length ? topSectors : backupSectors;
|
||||
const temp = marketTemperature?.temperature ?? board?.metrics.temperature ?? 0;
|
||||
|
||||
const priority = [
|
||||
actionable[0]
|
||||
? `先盯 ${actionable[0].name}${actionable[0].trigger_condition ? `,触发条件是 ${actionable[0].trigger_condition}` : " 的确认信号"}。`
|
||||
: focusSectors[0]
|
||||
? `主看 ${focusSectors[0]} 前排是否继续强化,再决定是否参与。`
|
||||
: "先观察龙头强度和市场承接,等待更清晰信号。",
|
||||
focusSectors[1]
|
||||
? `把 ${focusSectors.slice(0, 2).join("、")} 作为主线池,不要同时追太多方向。`
|
||||
: "今天只围绕一条最强主线做决策,避免来回切换。",
|
||||
temp >= 50 ? "优先做分歧后的回流和确认,不做无量冲高。" : "优先保守观察,只有最强确认才考虑出手。",
|
||||
];
|
||||
|
||||
const watchItems = [
|
||||
watch[0]
|
||||
? `${watch[0].name} 处于等待确认阶段,先看量能、板块回流和前排承接。`
|
||||
: focusSectors[0]
|
||||
? `${focusSectors[0]} 仍值得盯,但不满足确认前不追。`
|
||||
: "观察是否会出现新的主线聚焦。",
|
||||
watch[1]
|
||||
? `${watch[1].name} 适合放进观察队列,不在首页直接下结论。`
|
||||
: "盘中若出现新热点,先确认是否有板块扩散,再决定是否纳入观察。",
|
||||
observe.length > 0 ? `其余 ${observe.length} 只候选保持后台观察,不占用首页决策空间。` : "没有必要在首页堆更多弱标的。",
|
||||
];
|
||||
|
||||
const avoid = [
|
||||
board?.avoid_rules?.[0] ?? "回避没有主线归属、没有触发条件的个股。",
|
||||
board?.avoid_rules?.[1] ?? "回避后排补涨和尾盘情绪化拉升。",
|
||||
temp < 40 ? "回避逆势加仓和高频换股。" : "回避脱离计划的追涨杀跌。",
|
||||
];
|
||||
|
||||
return { priority, watch: watchItems, avoid };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { fetchAPI } from "@/lib/api";
|
||||
import type { PerformanceStats, StrategyIterationReport, StrategyStat, StrategyAdjustment } from "@/lib/api";
|
||||
import type {
|
||||
PerformanceStats,
|
||||
StrategyAdjustment,
|
||||
StrategyIterationReport,
|
||||
StrategyStat,
|
||||
} from "@/lib/api";
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
tighten: "收紧",
|
||||
@ -34,6 +39,8 @@ export default function StrategyPage() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const diagnosis = useMemo(() => buildCalibrationDiagnosis(iteration, performance), [iteration, performance]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 space-y-4">
|
||||
@ -46,84 +53,216 @@ export default function StrategyPage() {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||
<div className="animate-fade-in-up">
|
||||
<h1 className="text-xl font-bold tracking-tight">策略复盘</h1>
|
||||
<h1 className="text-xl font-bold tracking-tight">系统校准</h1>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
这里不是告诉你今天买什么,而是告诉系统最近什么方法有效、什么方法无效、下一轮该怎么改。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 animate-fade-in-up">
|
||||
<MetricCard label="复盘样本" value={iteration?.sample_size ?? 0} />
|
||||
<MetricCard label="整体胜率" value={`${(performance?.win_rate ?? 0).toFixed(1)}%`} tone={(performance?.win_rate ?? 0) >= 50 ? "up" : "down"} />
|
||||
<MetricCard label="平均收益" value={`${(performance?.avg_return ?? 0) > 0 ? "+" : ""}${(performance?.avg_return ?? 0).toFixed(2)}%`} tone={(performance?.avg_return ?? 0) >= 0 ? "up" : "down"} />
|
||||
<MetricCard label="平均回撤" value={`${(performance?.avg_max_drawdown ?? 0).toFixed(2)}%`} tone="down" />
|
||||
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4">
|
||||
<div>
|
||||
<div className="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">{diagnosis.headline}</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary">{diagnosis.detail}</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<DecisionList title="这页是做什么的" items={diagnosis.useFor} tone="positive" />
|
||||
<DecisionList title="这页不是做什么的" items={diagnosis.notFor} tone="risk" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 self-start">
|
||||
<MetricCard label="复盘样本" value={iteration?.sample_size ?? 0} />
|
||||
<MetricCard label="整体胜率" value={`${(performance?.win_rate ?? 0).toFixed(1)}%`} tone={(performance?.win_rate ?? 0) >= 50 ? "up" : "down"} />
|
||||
<MetricCard label="平均收益" value={`${(performance?.avg_return ?? 0) > 0 ? "+" : ""}${(performance?.avg_return ?? 0).toFixed(2)}%`} tone={(performance?.avg_return ?? 0) >= 0 ? "up" : "down"} />
|
||||
<MetricCard label="平均回撤" value={`${(performance?.avg_max_drawdown ?? 0).toFixed(2)}%`} tone="down" />
|
||||
<MetricFact label="已跟踪" value={`${performance?.tracked ?? 0} 只`} />
|
||||
<MetricFact label="本页角色" value="方法迭代,不做盘中执行" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{iteration ? (
|
||||
<>
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||||
<div>
|
||||
<SectionTitle title="复盘摘要" />
|
||||
<p className="text-sm text-text-secondary leading-relaxed mt-2 max-w-3xl">
|
||||
{iteration.summary}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px] gap-4">
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||||
<div>
|
||||
<SectionTitle title="校准摘要" />
|
||||
<p className="mt-2 text-sm leading-7 text-text-secondary">
|
||||
{iteration.summary}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-[10px] text-text-muted font-mono tabular-nums">
|
||||
{new Date(iteration.generated_at).toLocaleString("zh-CN")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-[10px] text-text-muted font-mono tabular-nums">
|
||||
{new Date(iteration.generated_at).toLocaleString("zh-CN")}
|
||||
{iteration.ai_analysis ? (
|
||||
<div className="mt-4 rounded-2xl border border-cyan-500/10 bg-cyan-500/[0.04] p-4 text-sm leading-7 text-cyan-400/85">
|
||||
{iteration.ai_analysis}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<SectionTitle title="应该怎么用" />
|
||||
<div className="mt-3 space-y-3">
|
||||
<UsageCard
|
||||
title="先看系统有没有偏掉"
|
||||
description="如果整体胜率、平均收益、回撤都在恶化,说明不是个股问题,而是推荐方法本身需要收紧。"
|
||||
/>
|
||||
<UsageCard
|
||||
title="再看哪类方法有效"
|
||||
description="按策略、按信号拆开看,判断是突破、回踩还是某类策略最近更有效。"
|
||||
/>
|
||||
<UsageCard
|
||||
title="最后把结果回写到下一轮"
|
||||
description="这里的目标不是描述过去,而是生成下一轮该加强、该降权、该观察的系统指令。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{iteration.ai_analysis && (
|
||||
<div className="mt-4 rounded-xl bg-cyan-500/[0.04] border border-cyan-500/10 p-4 text-sm text-cyan-400/85 leading-relaxed">
|
||||
{iteration.ai_analysis}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<SectionTitle title="下一轮策略指令" />
|
||||
<div className="space-y-3 mt-3">
|
||||
{(iteration.adjustment_suggestions.length ? iteration.adjustment_suggestions : [
|
||||
{ target: "推荐系统", action: "observe", reason: "等待更多跟踪样本后再调整策略权重。", confidence: "低" },
|
||||
]).slice(0, 5).map((item, index) => (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionTitle title="下一轮系统指令" />
|
||||
<span className="text-xs text-text-muted">这些结论应该影响下一轮推荐方法,而不是只停留在页面上。</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(iteration.adjustment_suggestions.length
|
||||
? iteration.adjustment_suggestions
|
||||
: [{ target: "推荐系统", action: "observe", reason: "等待更多跟踪样本后再调整策略权重。", confidence: "低" }]
|
||||
).slice(0, 6).map((item, index) => (
|
||||
<NextInstruction key={`${item.target}-${index}`} item={item} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<StatsPanel title="按策略表现" stats={iteration.strategy_stats} />
|
||||
<StatsPanel title="按信号表现" stats={iteration.signal_stats} />
|
||||
<StatsPanel
|
||||
title="哪些策略更有效"
|
||||
description="看不同策略分组最近表现,判断哪类打法该保留、该削弱。"
|
||||
stats={iteration.strategy_stats}
|
||||
/>
|
||||
<StatsPanel
|
||||
title="哪些信号更有效"
|
||||
description="看突破、回踩、启动等信号的兑现质量,而不是只看出现频次。"
|
||||
stats={iteration.signal_stats}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<SectionTitle title="失败模式" />
|
||||
<div className="space-y-2 mt-3">
|
||||
{iteration.failure_patterns.map((pattern, index) => (
|
||||
<div key={index} className="rounded-xl bg-surface-1 border border-border-subtle px-3 py-2.5 text-xs text-text-secondary leading-relaxed">
|
||||
{pattern}
|
||||
<SectionTitle title="最近的失效模式" />
|
||||
<div className="mt-2 text-xs text-text-muted">
|
||||
这些不是给你盘中参考的,而是告诉系统哪些错误不该继续重复。
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{iteration.failure_patterns.length ? (
|
||||
iteration.failure_patterns.map((pattern, index) => (
|
||||
<div key={index} className="rounded-2xl border border-border-subtle bg-surface-1 px-3 py-3 text-sm leading-6 text-text-secondary">
|
||||
{pattern}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1 px-3 py-3 text-sm text-text-muted">
|
||||
暂无明确失效模式。
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="glass-card-static p-10 text-center">
|
||||
<div className="text-text-muted text-sm">暂无策略复盘数据</div>
|
||||
<div className="text-text-muted/50 text-xs mt-1">等待推荐跟踪产生后自动生成</div>
|
||||
<div className="text-text-muted text-sm">暂无系统校准数据</div>
|
||||
<div className="text-text-muted/50 text-xs mt-1">等待推荐进入跟踪和闭环后,再生成方法迭代结论</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NextInstruction({ item, index }: { item: StrategyAdjustment; index: number }) {
|
||||
const verb = ACTION_LABELS[item.action] ?? item.action;
|
||||
const color = item.action === "promote"
|
||||
? "text-red-400 bg-red-500/[0.04] border-red-500/15"
|
||||
: item.action === "tighten" || item.action === "reduce"
|
||||
? "text-amber-400 bg-amber-500/[0.04] border-amber-500/15"
|
||||
: "text-cyan-400 bg-cyan-500/[0.04] border-cyan-500/15";
|
||||
function buildCalibrationDiagnosis(
|
||||
iteration: StrategyIterationReport | null,
|
||||
performance: PerformanceStats | null
|
||||
) {
|
||||
const winRate = performance?.win_rate ?? 0;
|
||||
const avgReturn = performance?.avg_return ?? 0;
|
||||
const tracked = performance?.tracked ?? 0;
|
||||
const headline =
|
||||
tracked < 10
|
||||
? "当前更像早期样本积累阶段"
|
||||
: winRate >= 55 && avgReturn >= 0
|
||||
? "当前方法仍然有效,但需要持续校准"
|
||||
: "当前方法出现退化,需要收紧与调整";
|
||||
|
||||
const detail =
|
||||
iteration?.summary ??
|
||||
(tracked < 10
|
||||
? "闭环样本还不够多,这一页更适合看方向性的偏差,而不是做强结论。"
|
||||
: "这个页面的目标是判断推荐方法最近有没有偏掉,以及下一轮应该如何调整。");
|
||||
|
||||
const useFor = [
|
||||
"验证系统最近推荐出来的东西,长期看是否真的有效。",
|
||||
"识别哪类策略、哪类信号最近更有效或更容易失效。",
|
||||
"把复盘结论转成下一轮推荐系统的收紧、加强或降权指令。",
|
||||
];
|
||||
|
||||
const notFor = [
|
||||
"不是盘中决策页,不负责告诉你现在立刻买哪只股票。",
|
||||
"不是板块行情页,不负责追踪今天最热方向。",
|
||||
"不是个股详情页,不负责展开单只股票的全部逻辑。",
|
||||
];
|
||||
|
||||
return { headline, detail, useFor, notFor };
|
||||
}
|
||||
|
||||
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-xl border p-3 ${color}`}>
|
||||
<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 className="leading-6">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageCard({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
||||
<div className="text-sm font-semibold text-text-primary">{title}</div>
|
||||
<div className="mt-1 text-xs leading-6 text-text-muted">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NextInstruction({ item, index }: { item: StrategyAdjustment; index: number }) {
|
||||
const verb = ACTION_LABELS[item.action] ?? item.action;
|
||||
const color =
|
||||
item.action === "promote"
|
||||
? "text-red-400 bg-red-500/[0.04] border-red-500/15"
|
||||
: item.action === "tighten" || item.action === "reduce"
|
||||
? "text-amber-400 bg-amber-500/[0.04] border-amber-500/15"
|
||||
: "text-cyan-400 bg-cyan-500/[0.04] border-cyan-500/15";
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border p-3 ${color}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider opacity-80 mb-1">
|
||||
@ -131,17 +270,26 @@ function NextInstruction({ item, index }: { item: StrategyAdjustment; index: num
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-text-primary">{item.target}</div>
|
||||
</div>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-2 border border-border-subtle text-text-muted shrink-0">
|
||||
<span className="shrink-0 rounded-full border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
|
||||
置信 {item.confidence}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs leading-relaxed text-text-secondary mt-2">{item.reason}</div>
|
||||
<div className="mt-2 text-sm leading-6 text-text-secondary">{item.reason}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, tone }: { label: string; value: string | number; tone?: "up" | "down" }) {
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
tone?: "up" | "down";
|
||||
}) {
|
||||
const color = tone === "up" ? "text-red-400" : tone === "down" ? "text-emerald-400" : "text-text-primary";
|
||||
|
||||
return (
|
||||
<div className="glass-card-static p-4">
|
||||
<div className="text-[10px] text-text-muted/60 mb-1">{label}</div>
|
||||
@ -150,27 +298,47 @@ function MetricCard({ label, value, tone }: { label: string; value: string | num
|
||||
);
|
||||
}
|
||||
|
||||
function StatsPanel({ title, stats }: { title: string; stats: StrategyStat[] }) {
|
||||
function MetricFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="glass-card-static p-4">
|
||||
<div className="text-[10px] text-text-muted/60 mb-1">{label}</div>
|
||||
<div className="text-xs font-semibold leading-5 text-text-secondary">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsPanel({
|
||||
title,
|
||||
description,
|
||||
stats,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
stats: StrategyStat[];
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||||
<SectionTitle title={title} />
|
||||
<div className="space-y-2 mt-3">
|
||||
{stats.length ? stats.slice(0, 6).map((stat) => (
|
||||
<div key={stat.name} className="rounded-xl bg-surface-1 border border-border-subtle p-3">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="text-sm font-semibold text-text-primary truncate">{stat.name}</div>
|
||||
<div className={`text-sm font-bold font-mono tabular-nums ${stat.avg_return > 0 ? "text-red-400" : stat.avg_return < 0 ? "text-emerald-400" : "text-text-secondary"}`}>
|
||||
{stat.avg_return > 0 ? "+" : ""}{stat.avg_return.toFixed(2)}%
|
||||
<div className="mt-1 text-xs text-text-muted">{description}</div>
|
||||
<div className="space-y-2 mt-4">
|
||||
{stats.length ? (
|
||||
stats.slice(0, 6).map((stat) => (
|
||||
<div key={stat.name} className="rounded-2xl bg-surface-1 border border-border-subtle p-3">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="text-sm font-semibold text-text-primary truncate">{stat.name}</div>
|
||||
<div className={`text-sm font-bold font-mono tabular-nums ${stat.avg_return > 0 ? "text-red-400" : stat.avg_return < 0 ? "text-emerald-400" : "text-text-secondary"}`}>
|
||||
{stat.avg_return > 0 ? "+" : ""}{stat.avg_return.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 text-[10px] text-text-muted">
|
||||
<StatCell label="样本" value={stat.count} />
|
||||
<StatCell label="胜率" value={`${stat.win_rate.toFixed(1)}%`} />
|
||||
<StatCell label="浮盈" value={`${stat.avg_max_return > 0 ? "+" : ""}${stat.avg_max_return.toFixed(1)}%`} />
|
||||
<StatCell label="回撤" value={`${stat.avg_max_drawdown.toFixed(1)}%`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 text-[10px] text-text-muted">
|
||||
<StatCell label="样本" value={stat.count} />
|
||||
<StatCell label="胜率" value={`${stat.win_rate.toFixed(1)}%`} />
|
||||
<StatCell label="浮盈" value={`${stat.avg_max_return > 0 ? "+" : ""}${stat.avg_max_return.toFixed(1)}%`} />
|
||||
<StatCell label="回撤" value={`${stat.avg_max_drawdown.toFixed(1)}%`} />
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-text-muted">暂无分组数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -22,107 +22,83 @@ export default function MarketTemp({ data, indices }: MarketTempProps) {
|
||||
const ratio = data.up_count / Math.max(data.down_count, 1);
|
||||
const hasBrokenRate = data.broken_rate != null && data.broken_rate > 0;
|
||||
const hasMaxStreak = data.max_streak != null && data.max_streak > 0;
|
||||
const limitCountsReliable = data.limit_counts_reliable !== false;
|
||||
const posture =
|
||||
data.temperature >= 70 ? "可以积极进攻,但仍只围绕主线前排。" :
|
||||
data.temperature >= 50 ? "可以试错,但要优先做确认后的机会。" :
|
||||
data.temperature >= 30 ? "市场分化偏弱,控制仓位,只做最强方向。" :
|
||||
"更适合观察,减少主动试错。";
|
||||
const riskTag =
|
||||
ratio > 1.2 ? "情绪偏正" :
|
||||
ratio < 0.8 ? "空方占优" :
|
||||
"多空拉锯";
|
||||
|
||||
return (
|
||||
<div className="glass-card-static p-4 sm:p-5 animate-fade-in-up">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3 sm:mb-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">大盘温度证据</h2>
|
||||
<p className="text-[10px] text-text-muted/60 mt-0.5">用于决定仓位上限和进攻/防守倾向</p>
|
||||
<h2 className="text-sm font-semibold text-text-muted">市场状态</h2>
|
||||
<p className="text-[10px] text-text-muted/60 mt-0.5">用于决定仓位、节奏和今天到底该不该出手。</p>
|
||||
</div>
|
||||
<span className="text-[10px] sm:text-xs text-text-muted font-mono tabular-nums">{data.trade_date}</span>
|
||||
</div>
|
||||
|
||||
{/* Top row: Gauge + Stats grid */}
|
||||
<div className="flex items-center gap-3 sm:gap-5 mb-3 sm:mb-4">
|
||||
{/* Circular gauge */}
|
||||
<div className="relative w-16 sm:w-20 md:w-24 h-16 sm:h-20 md:h-24 flex-shrink-0">
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full -rotate-90">
|
||||
<defs>
|
||||
<linearGradient id="tempGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="40" fill="none" stroke="rgba(148,163,184,0.06)" strokeWidth="7" />
|
||||
<circle
|
||||
cx="50" cy="50" r="40" fill="none"
|
||||
stroke="url(#tempGrad)" strokeWidth="7"
|
||||
strokeDasharray={`${data.temperature * 2.51} 251`}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-1000 ease-out"
|
||||
/>
|
||||
<circle
|
||||
cx="50" cy="50" r="40" fill="none"
|
||||
stroke={color} strokeWidth="7"
|
||||
strokeDasharray={`${data.temperature * 2.51} 251`}
|
||||
strokeLinecap="round"
|
||||
opacity="0.2"
|
||||
filter="blur(4px)"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-base sm:text-lg md:text-xl font-bold font-mono tabular-nums" style={{ color }}>{data.temperature}</span>
|
||||
<span className="text-[10px] sm:text-xs text-text-muted font-medium mt-0.5">{label}</span>
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-1 px-4 py-3 mb-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[11px] text-text-muted">今日结论</div>
|
||||
<div className="text-lg font-semibold mt-1" style={{ color }}>{label}</div>
|
||||
<div className="text-xs text-text-secondary mt-2">{posture}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-mono tabular-nums font-bold" style={{ color }}>{data.temperature}</div>
|
||||
<div className="text-[11px] text-text-muted mt-1">{riskTag}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key stats grid 3x2 */}
|
||||
<div className="flex-1 grid grid-cols-3 gap-1.5 sm:gap-2">
|
||||
<StatCard label="涨停">
|
||||
<span className="text-red-400 font-mono tabular-nums font-semibold">{data.limit_up_count}</span>
|
||||
</StatCard>
|
||||
<StatCard label="跌停">
|
||||
<span className="text-emerald-400 font-mono tabular-nums font-semibold">{data.limit_down_count ?? 0}</span>
|
||||
</StatCard>
|
||||
<StatCard label="涨跌比">
|
||||
<span className={`font-mono tabular-nums font-semibold ${ratio > 1 ? "text-red-400" : "text-emerald-400"}`}>
|
||||
{ratio.toFixed(2)}
|
||||
</span>
|
||||
</StatCard>
|
||||
<StatCard label="上涨">
|
||||
<span className="text-red-400 font-mono tabular-nums">{data.up_count}</span>
|
||||
</StatCard>
|
||||
<StatCard label="下跌">
|
||||
<span className="text-emerald-400 font-mono tabular-nums">{data.down_count}</span>
|
||||
</StatCard>
|
||||
<StatCard label={hasMaxStreak ? "最高连板" : "连板"}>
|
||||
<span className="text-amber-400 font-mono tabular-nums font-semibold">
|
||||
{hasMaxStreak ? `${data.max_streak}连板` : "-"}
|
||||
</span>
|
||||
</StatCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle row: Broken rate + MA20 */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
<StatCard label="上涨/下跌">
|
||||
<span className="font-mono tabular-nums text-text-primary">{data.up_count} / {data.down_count}</span>
|
||||
</StatCard>
|
||||
<StatCard label="涨跌比">
|
||||
<span className={`font-mono tabular-nums font-semibold ${ratio > 1 ? "text-red-400" : "text-emerald-400"}`}>
|
||||
{ratio.toFixed(2)}
|
||||
</span>
|
||||
</StatCard>
|
||||
<StatCard label="最高连板">
|
||||
<span className="text-amber-400 font-mono tabular-nums font-semibold">
|
||||
{hasMaxStreak ? `${data.max_streak}连板` : "-"}
|
||||
</span>
|
||||
</StatCard>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||
<div 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">炸板率</div>
|
||||
{hasBrokenRate ? (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-xs sm:text-sm font-mono tabular-nums font-semibold text-amber-400">
|
||||
{data.broken_rate!.toFixed(1)}%
|
||||
</span>
|
||||
{data.broken_rate! > 50 && (
|
||||
<span className="text-[10px] sm:text-xs text-amber-500/60">偏高</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs sm:text-sm text-text-muted/40">-</span>
|
||||
)}
|
||||
<div className="text-[10px] sm:text-xs text-text-muted mb-0.5">涨停 / 跌停</div>
|
||||
<div className="text-xs sm:text-sm font-mono tabular-nums">
|
||||
<span className="text-red-400 font-semibold">{limitCountsReliable ? data.limit_up_count : "--"}</span>
|
||||
<span className="text-text-muted mx-1">/</span>
|
||||
<span className="text-emerald-400 font-semibold">{limitCountsReliable ? (data.limit_down_count ?? 0) : "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div 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">上证均线</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="text-[10px] sm:text-xs text-text-muted mb-0.5">风控提示</div>
|
||||
<div className="flex items-center gap-1.5 text-xs sm:text-sm">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${data.index_above_ma20 ? "bg-red-400" : "bg-emerald-400"}`} />
|
||||
<span className={`text-xs sm:text-sm font-semibold ${data.index_above_ma20 ? "text-red-400" : "text-emerald-400"}`}>
|
||||
{data.index_above_ma20 ? "均线之上" : "均线之下"}
|
||||
<span className={`${data.index_above_ma20 ? "text-red-400" : "text-emerald-400"}`}>
|
||||
{data.index_above_ma20 ? "指数仍有承接" : "指数仍偏弱"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!limitCountsReliable && (
|
||||
<div className="text-[10px] text-text-muted/60 mb-2">
|
||||
涨停/跌停池实时计数暂未确认,当前未展示不可靠的 0 值。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom row: 3 major indices in one row */}
|
||||
{indices && indices.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
|
||||
@ -116,7 +116,7 @@ export function SidebarNav() {
|
||||
<nav className="flex-1 py-5 px-3 space-y-1">
|
||||
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今日作战" />
|
||||
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐池" />
|
||||
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="策略复盘" />
|
||||
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="系统校准" />
|
||||
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块主线" />
|
||||
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" />
|
||||
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="个股诊断" />
|
||||
@ -155,7 +155,7 @@ export function MobileBottomNav() {
|
||||
<MobileNavItem href="/recommendations" label="推荐池">
|
||||
<TargetIcon />
|
||||
</MobileNavItem>
|
||||
<MobileNavItem href="/strategy" label="策略">
|
||||
<MobileNavItem href="/strategy" label="校准">
|
||||
<StrategyIcon />
|
||||
</MobileNavItem>
|
||||
<MobileNavItem href="/watchlists" label="自选">
|
||||
|
||||
@ -3,115 +3,121 @@
|
||||
import type { SectorData } from "@/lib/api";
|
||||
import { formatNumber } from "@/lib/utils";
|
||||
|
||||
function getSectorHeadline(sector: SectorData, index: number): string {
|
||||
const pct = sector.realtime_pct_change ?? sector.pct_change;
|
||||
const breadth = (sector.realtime_up_count ?? 0) - (sector.realtime_down_count ?? 0);
|
||||
if (index === 0 && pct >= 2) return "当前最强主线,优先盯一致性";
|
||||
if (breadth > 10) return "板块扩散明显,留意前排分歧后的回流";
|
||||
if (pct > 0) return "仍在轮动窗口,观察能否继续放大";
|
||||
return "强度一般,更适合观察不适合追高";
|
||||
}
|
||||
|
||||
function getSectorFocus(sector: SectorData): string {
|
||||
const leaders = sector.leading_stocks_realtime?.length
|
||||
? sector.leading_stocks_realtime
|
||||
: (sector.leading_stocks ?? []);
|
||||
if (leaders.length > 0) {
|
||||
return `盯住 ${leaders.slice(0, 2).map((item) => item.name).join(" / ")} 的承接和回流。`;
|
||||
}
|
||||
if ((sector.realtime_up_count ?? 0) > (sector.realtime_down_count ?? 0)) {
|
||||
return "板块内上涨家数占优,优先看前排继续强化。";
|
||||
}
|
||||
return "板块内分化较重,只保留观察,不追后排。";
|
||||
}
|
||||
|
||||
export default function SectorHeatmap({ sectors }: { sectors: SectorData[] }) {
|
||||
if (!sectors.length) {
|
||||
return (
|
||||
<div className="glass-card-static p-5">
|
||||
<h2 className="text-sm font-semibold text-text-muted mb-4">主线板块证据</h2>
|
||||
<h2 className="text-sm font-semibold text-text-muted mb-4">今日主线</h2>
|
||||
<div className="text-sm text-text-muted text-center py-6">暂无数据</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxScore = Math.max(...sectors.map((s) => s.heat_score));
|
||||
const mainSectors = sectors.slice(0, 3);
|
||||
const hasRealtime = sectors.some((s) => s.is_realtime);
|
||||
|
||||
return (
|
||||
<div className="glass-card-static p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="glass-card-static p-4 sm:p-5 animate-fade-in-up">
|
||||
<div className="flex items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-muted">主线板块证据</h2>
|
||||
<p className="text-[10px] text-text-muted/60 mt-0.5">用于验证推荐是否站在资金和赚钱效应方向上</p>
|
||||
<h2 className="text-sm font-semibold text-text-muted">今日主线</h2>
|
||||
<p className="text-[10px] text-text-muted/60 mt-0.5">
|
||||
不是展示板块分数,而是告诉你今天该盯什么方向、看什么前排。
|
||||
</p>
|
||||
</div>
|
||||
{hasRealtime && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400/80">
|
||||
实时
|
||||
今日实时
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sectors.map((s, index) => {
|
||||
const intensity = s.heat_score / Math.max(maxScore, 1);
|
||||
// 盘中使用实时涨幅,否则用日级涨幅
|
||||
const displayPct = s.realtime_pct_change ?? s.pct_change;
|
||||
const isUp = displayPct > 0;
|
||||
const isTop3 = index < 3;
|
||||
// 盘中使用实时涨停数
|
||||
const displayLimitUp = s.realtime_limit_up_count ?? s.limit_up_count;
|
||||
const displayAmount = s.realtime_amount ?? s.capital_inflow;
|
||||
const displayBreadth = s.realtime_up_count != null && s.realtime_down_count != null
|
||||
? `${s.realtime_up_count}/${s.realtime_down_count}`
|
||||
: null;
|
||||
|
||||
// Bar width based on score relative to max
|
||||
const barWidth = `${Math.max(intensity * 100, 15)}%`;
|
||||
<div className="space-y-3">
|
||||
{mainSectors.map((sector, index) => {
|
||||
const pct = sector.realtime_pct_change ?? sector.pct_change;
|
||||
const amount = sector.realtime_amount ?? sector.capital_inflow;
|
||||
const up = sector.realtime_up_count;
|
||||
const down = sector.realtime_down_count;
|
||||
const leaders = sector.leading_stocks_realtime?.length
|
||||
? sector.leading_stocks_realtime
|
||||
: (sector.leading_stocks ?? []);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.sector_code}
|
||||
className="relative rounded-xl overflow-hidden animate-fade-in-up"
|
||||
style={{ animationDelay: `${index * 40}ms` }}
|
||||
>
|
||||
{/* Colored bar background - width proportional to heat score */}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-xl transition-all duration-500"
|
||||
style={{
|
||||
width: barWidth,
|
||||
background: isUp
|
||||
? `linear-gradient(90deg, rgba(239, 68, 68, ${0.06 + intensity * 0.18}), rgba(239, 68, 68, ${0.02 + intensity * 0.06}))`
|
||||
: `linear-gradient(90deg, rgba(34, 197, 94, ${0.06 + intensity * 0.18}), rgba(34, 197, 94, ${0.02 + intensity * 0.06}))`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative flex items-center justify-between px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Rank number */}
|
||||
<span className={`w-6 h-6 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 ${
|
||||
index === 0
|
||||
? "bg-gradient-to-br from-amber-500/30 to-amber-600/20 text-amber-400 border border-amber-500/20"
|
||||
: index === 1
|
||||
? "bg-gradient-to-br from-slate-400/20 to-slate-500/15 text-slate-300 border border-slate-400/15"
|
||||
: index === 2
|
||||
? "bg-gradient-to-br from-amber-700/20 to-amber-800/15 text-amber-400 border border-amber-600/15"
|
||||
: "bg-surface-2 text-text-muted border border-border-subtle"
|
||||
}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<div>
|
||||
<span className={`text-sm font-medium ${isTop3 ? "text-text-primary" : "text-text-secondary"}`}>
|
||||
{s.sector_name}
|
||||
<div key={sector.sector_code} className="rounded-2xl border border-border-subtle bg-surface-1 px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-6 h-6 rounded-lg flex items-center justify-center text-xs font-semibold ${
|
||||
index === 0
|
||||
? "bg-amber-500/20 text-amber-400"
|
||||
: "bg-surface-2 text-text-secondary"
|
||||
}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
{displayLimitUp > 0 && (
|
||||
<span className="text-xs text-text-muted ml-2">
|
||||
涨停 {displayLimitUp}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-sm font-semibold text-text-primary">{sector.sector_name}</div>
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary mt-2">
|
||||
{getSectorHeadline(sector, index)}
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted mt-1.5">
|
||||
{getSectorFocus(sector)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className={`font-mono tabular-nums ${displayAmount > 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||
{displayAmount > 0 ? "+" : ""}
|
||||
{formatNumber(displayAmount)}
|
||||
</span>
|
||||
<span className={`font-mono tabular-nums font-semibold min-w-[60px] text-right ${isUp ? "text-red-400" : "text-emerald-400"}`}>
|
||||
{displayPct > 0 ? "+" : ""}
|
||||
{displayPct.toFixed(2)}%
|
||||
</span>
|
||||
{displayBreadth && (
|
||||
<span className="font-mono tabular-nums text-xs text-text-secondary min-w-[56px] text-right">
|
||||
{displayBreadth}
|
||||
</span>
|
||||
)}
|
||||
{/* Heat score pill */}
|
||||
<span className={`font-mono tabular-nums text-xs font-semibold px-2 py-1 rounded-lg min-w-[36px] text-center ${
|
||||
isTop3
|
||||
? "bg-amber-500/20 text-amber-400 border border-amber-500/15"
|
||||
: "bg-surface-3 text-text-muted border border-border-subtle"
|
||||
}`}>
|
||||
{s.heat_score.toFixed(0)}
|
||||
</span>
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
<div className={`text-sm font-mono tabular-nums font-semibold ${pct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||
{pct >= 0 ? "+" : ""}{pct.toFixed(2)}%
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted mt-1">
|
||||
{amount > 0 ? `成交 ${formatNumber(amount)}` : "成交额待补充"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-3 text-[11px]">
|
||||
{up != null && down != null ? (
|
||||
<span className="px-2 py-1 rounded-lg bg-surface-2 text-text-secondary">
|
||||
广度 {up}/{down}
|
||||
</span>
|
||||
) : null}
|
||||
{sector.stage ? (
|
||||
<span className="px-2 py-1 rounded-lg bg-surface-2 text-text-secondary">
|
||||
阶段 {sector.stage}
|
||||
</span>
|
||||
) : null}
|
||||
{leaders[0] ? (
|
||||
<span className="px-2 py-1 rounded-lg bg-surface-2 text-text-secondary">
|
||||
前排 {leaders.slice(0, 2).map((item) => item.name).join(" / ")}
|
||||
</span>
|
||||
) : null}
|
||||
{(sector.realtime_limit_up_count ?? sector.limit_up_count) > 0 ? (
|
||||
<span className="px-2 py-1 rounded-lg bg-red-500/10 text-red-400">
|
||||
涨停 {(sector.realtime_limit_up_count ?? sector.limit_up_count)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -3,15 +3,16 @@
|
||||
import { getLevelBadge } from "@/lib/utils";
|
||||
import type { RecommendationData } from "@/lib/api";
|
||||
|
||||
export default function StockCard({ rec }: { rec: RecommendationData }) {
|
||||
export default function StockCard({ rec, compact = false }: { rec: RecommendationData; compact?: boolean }) {
|
||||
const badge = getLevelBadge(rec.level);
|
||||
const aiConviction = rec.llm_score != null ? Math.round(rec.llm_score) : null;
|
||||
const recallLabels: Record<string, string> = {
|
||||
sector_recall: "主线召回",
|
||||
trend_scan: "趋势召回",
|
||||
intraday_active: "盘中异动",
|
||||
hot_sector_core: "板块核心",
|
||||
sector_leader: "前排线索",
|
||||
hot_theme_core: "主题核心",
|
||||
theme_leader: "主题前排",
|
||||
top_theme_member: "主线主题成分",
|
||||
moneyflow_support: "资金支撑",
|
||||
volume_active: "量能活跃",
|
||||
};
|
||||
@ -62,6 +63,9 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
|
||||
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">
|
||||
@ -148,7 +152,30 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{evidence.length > 0 && (
|
||||
{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">
|
||||
@ -172,7 +199,7 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(rec.focus_points?.length ?? 0) > 0 && (
|
||||
{!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">
|
||||
@ -204,7 +231,7 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rec.tracking && (
|
||||
{!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">
|
||||
@ -237,19 +264,21 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
|
||||
)}
|
||||
|
||||
{/* Reasons */}
|
||||
<div className="space-y-1.5">
|
||||
{rec.reasons.slice(0, 3).map((r, i) => (
|
||||
<div key={i} className="text-xs text-text-secondary flex items-start gap-2">
|
||||
<span className="w-1 h-1 rounded-full bg-amber-500/60 mt-[6px] shrink-0" />
|
||||
<span className="leading-relaxed line-clamp-2">{r}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!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
|
||||
@ -262,7 +291,7 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
|
||||
</div>
|
||||
|
||||
{/* Risk note */}
|
||||
{rec.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>
|
||||
|
||||
@ -103,6 +103,8 @@ export interface MarketTemperatureData {
|
||||
max_streak?: number;
|
||||
broken_rate?: number;
|
||||
index_above_ma20?: boolean;
|
||||
data_mode?: "realtime_today" | "daily_snapshot";
|
||||
limit_counts_reliable?: boolean;
|
||||
}
|
||||
|
||||
export interface IndexOverview {
|
||||
@ -177,6 +179,10 @@ export interface LeadingStock {
|
||||
export interface SectorData {
|
||||
sector_code: string;
|
||||
sector_name: string;
|
||||
board_type?: string;
|
||||
theme_id?: string;
|
||||
theme_name?: string;
|
||||
theme_aliases?: string[];
|
||||
pct_change: number;
|
||||
trade_date?: string;
|
||||
capital_inflow: number;
|
||||
@ -199,11 +205,23 @@ export interface SectorData {
|
||||
is_realtime?: boolean;
|
||||
data_mode?: "realtime_today" | "realtime_overlay" | "daily_snapshot";
|
||||
structure_trade_date?: string;
|
||||
source?: "eastmoney" | "sina" | "snapshot" | string;
|
||||
}
|
||||
|
||||
export interface LatestResult {
|
||||
market_temperature: MarketTemperatureData | null;
|
||||
recommendations: RecommendationData[];
|
||||
market_anomalies?: Array<{
|
||||
ts_code: string;
|
||||
name: string;
|
||||
sector: string;
|
||||
score: number;
|
||||
action_plan: string;
|
||||
recall_tags: string[];
|
||||
prefilter_decision: string;
|
||||
reason: string;
|
||||
created_at: string | null;
|
||||
}>;
|
||||
strategy_profile?: {
|
||||
strategy_id: string;
|
||||
name: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user