1
This commit is contained in:
parent
b699b185fc
commit
f3f43a5a5d
Binary file not shown.
93
backend/app/analysis/sector_realtime.py
Normal file
93
backend/app/analysis/sector_realtime.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
"""板块实时数据富化。
|
||||||
|
|
||||||
|
复用东方财富板块实时排名,为数据库中的板块快照补充今日实时涨幅、广度和成交额。
|
||||||
|
不触发扫描,只做轻量覆盖。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.config import should_prefer_realtime_today
|
||||||
|
from app.data.eastmoney_client import get_sector_realtime_ranking
|
||||||
|
from app.data.models import SectorInfo
|
||||||
|
from app.data.tushare_client import tushare_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _match_sector_name(em_name: str, ts_name: str) -> bool:
|
||||||
|
"""东方财富板块名与 Tushare 板块名模糊匹配。"""
|
||||||
|
em_clean = em_name.rstrip("行业").rstrip("板块").rstrip("概念").strip()
|
||||||
|
ts_clean = ts_name.rstrip("行业").rstrip("板块").rstrip("概念").strip()
|
||||||
|
if em_clean == ts_clean:
|
||||||
|
return True
|
||||||
|
short, long = (em_clean, ts_clean) if len(em_clean) <= len(ts_clean) else (ts_clean, em_clean)
|
||||||
|
return short in long
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_empty_overlay(sector: SectorInfo) -> SectorInfo:
|
||||||
|
sector.realtime_pct_change = None
|
||||||
|
sector.realtime_limit_up_count = None
|
||||||
|
sector.realtime_amount = None
|
||||||
|
sector.realtime_turnover_rate = None
|
||||||
|
sector.realtime_up_count = None
|
||||||
|
sector.realtime_down_count = None
|
||||||
|
sector.leading_stocks_realtime = []
|
||||||
|
sector.is_realtime = False
|
||||||
|
sector.data_mode = "daily_snapshot"
|
||||||
|
return sector
|
||||||
|
|
||||||
|
|
||||||
|
async def enrich_sectors_with_realtime(sectors: list[SectorInfo]) -> list[SectorInfo]:
|
||||||
|
"""按需为板块快照追加实时字段并重排。"""
|
||||||
|
if not sectors:
|
||||||
|
return sectors
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
try:
|
||||||
|
em_sectors = await get_sector_realtime_ranking()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("东方财富板块实时数据获取失败,回退到日级快照")
|
||||||
|
return [_apply_empty_overlay(sector) for sector in sectors]
|
||||||
|
|
||||||
|
if not em_sectors:
|
||||||
|
return [_apply_empty_overlay(sector) for sector in sectors]
|
||||||
|
|
||||||
|
em_name_map = {item["sector_name"]: item for item in em_sectors}
|
||||||
|
matched = 0
|
||||||
|
|
||||||
|
for sector in sectors:
|
||||||
|
em_data = em_name_map.get(sector.sector_name)
|
||||||
|
if not em_data:
|
||||||
|
for item in em_sectors:
|
||||||
|
if _match_sector_name(item["sector_name"], sector.sector_name):
|
||||||
|
em_data = item
|
||||||
|
break
|
||||||
|
|
||||||
|
if not em_data:
|
||||||
|
_apply_empty_overlay(sector)
|
||||||
|
continue
|
||||||
|
|
||||||
|
matched += 1
|
||||||
|
sector.realtime_pct_change = float(em_data.get("pct_change", 0) or 0)
|
||||||
|
sector.realtime_limit_up_count = None
|
||||||
|
sector.realtime_amount = round(float(em_data.get("amount", 0) or 0) / 10000, 2)
|
||||||
|
sector.realtime_turnover_rate = float(em_data.get("turnover_rate", 0) or 0)
|
||||||
|
sector.realtime_up_count = int(em_data.get("up_count", 0) or 0)
|
||||||
|
sector.realtime_down_count = int(em_data.get("down_count", 0) or 0)
|
||||||
|
sector.leading_stocks_realtime = []
|
||||||
|
if em_data.get("leading_stock_name"):
|
||||||
|
sector.leading_stocks_realtime = [{
|
||||||
|
"ts_code": em_data.get("leading_stock_code", ""),
|
||||||
|
"name": em_data.get("leading_stock_name", ""),
|
||||||
|
"pct_chg": float(em_data.get("leading_stock_pct", 0) or 0),
|
||||||
|
"amount": 0,
|
||||||
|
}]
|
||||||
|
sector.is_realtime = True
|
||||||
|
sector.data_mode = "realtime_overlay"
|
||||||
|
|
||||||
|
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
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from app.data.tushare_client import tushare_client
|
from app.data.tushare_client import tushare_client
|
||||||
from app.data import tencent_client
|
from app.data import tencent_client
|
||||||
from app.engine.recommender import get_latest_recommendations
|
from app.engine.recommender import get_latest_recommendations
|
||||||
from app.config import is_trading_hours, is_market_session
|
from app.config import is_trading_hours, is_market_session, should_prefer_realtime_today
|
||||||
from app.core.deps import get_current_admin
|
from app.core.deps import get_current_admin
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/market", tags=["market"])
|
router = APIRouter(prefix="/api/market", tags=["market"])
|
||||||
@ -51,7 +51,8 @@ async def get_overview():
|
|||||||
|
|
||||||
盘中用腾讯实时行情,盘后用 Tushare 日线(有缓存)。
|
盘中用腾讯实时行情,盘后用 Tushare 日线(有缓存)。
|
||||||
"""
|
"""
|
||||||
if is_market_session():
|
latest_trade_date = tushare_client.get_latest_trade_date()
|
||||||
|
if should_prefer_realtime_today(latest_trade_date):
|
||||||
return await _overview_realtime()
|
return await _overview_realtime()
|
||||||
return _overview_daily()
|
return _overview_daily()
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,8 @@ from app.engine.recommender import (
|
|||||||
get_recommendation_history,
|
get_recommendation_history,
|
||||||
get_performance_stats,
|
get_performance_stats,
|
||||||
)
|
)
|
||||||
from app.config import is_trading_hours
|
from app.config import is_trading_hours, should_prefer_realtime_today
|
||||||
|
from app.data.tushare_client import tushare_client
|
||||||
from app.core.deps import get_current_admin
|
from app.core.deps import get_current_admin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -151,10 +152,12 @@ async def update_tracking(_admin: dict = Depends(get_current_admin)):
|
|||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def get_scan_status():
|
async def get_scan_status():
|
||||||
"""获取当前扫描状态信息"""
|
"""获取当前扫描状态信息"""
|
||||||
|
latest_trade_date = tushare_client.get_latest_trade_date()
|
||||||
|
prefer_realtime = should_prefer_realtime_today(latest_trade_date)
|
||||||
return {
|
return {
|
||||||
"is_trading": is_trading_hours(),
|
"is_trading": is_trading_hours(),
|
||||||
"scan_mode": "intraday" if is_trading_hours() else "post_market",
|
"scan_mode": "intraday" if prefer_realtime else "post_market",
|
||||||
"description": "盘中实时扫描(腾讯行情)" if is_trading_hours() else "盘后分析(Tushare日级数据)",
|
"description": "今日实时分析优先" if prefer_realtime else "盘后分析(Tushare日级数据)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,114 +1,22 @@
|
|||||||
"""板块分析 API"""
|
"""板块分析 API"""
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.config import is_market_session
|
from app.analysis.sector_realtime import enrich_sectors_with_realtime
|
||||||
from app.data.tushare_client import tushare_client
|
from app.data.tushare_client import tushare_client
|
||||||
from app.data.eastmoney_client import get_sector_realtime_ranking
|
|
||||||
from app.data.cache import cache
|
from app.data.cache import cache
|
||||||
from app.engine.recommender import get_latest_sectors
|
from app.engine.recommender import get_latest_sectors
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter(prefix="/api/sectors", tags=["sectors"])
|
router = APIRouter(prefix="/api/sectors", tags=["sectors"])
|
||||||
|
|
||||||
|
|
||||||
def _match_sector_name(em_name: str, ts_name: str) -> bool:
|
|
||||||
"""东方财富板块名与 Tushare 板块名模糊匹配
|
|
||||||
|
|
||||||
东方财富用"酿酒行业",Tushare 可能叫"白酒";
|
|
||||||
东方财富用"汽车整车",Tushare 可能叫"汽车"。
|
|
||||||
用包含匹配(短名在长名中)或尾部去掉"行业"后完全匹配。
|
|
||||||
"""
|
|
||||||
# 去掉常见后缀再做比较
|
|
||||||
em_clean = em_name.rstrip("行业").rstrip("板块").rstrip("概念").strip()
|
|
||||||
ts_clean = ts_name.rstrip("行业").rstrip("板块").rstrip("概念").strip()
|
|
||||||
if em_clean == ts_clean:
|
|
||||||
return True
|
|
||||||
# 短名包含在长名中
|
|
||||||
short, long = (em_clean, ts_clean) if len(em_clean) <= len(ts_clean) else (ts_clean, em_clean)
|
|
||||||
return short in long
|
|
||||||
|
|
||||||
|
|
||||||
async def _enrich_sectors_realtime(sectors_data: list[dict]) -> list[dict]:
|
|
||||||
"""盘中时,用东方财富实时板块数据补充涨幅和涨停数
|
|
||||||
|
|
||||||
一次请求替代之前腾讯批量获取数千只成分股的方式。
|
|
||||||
"""
|
|
||||||
if not is_market_session():
|
|
||||||
for s in sectors_data:
|
|
||||||
s["realtime_pct_change"] = None
|
|
||||||
s["realtime_limit_up_count"] = None
|
|
||||||
s["is_realtime"] = False
|
|
||||||
return sectors_data
|
|
||||||
|
|
||||||
# 从东方财富获取实时板块排名(1次 HTTP 请求)
|
|
||||||
try:
|
|
||||||
em_sectors = await get_sector_realtime_ranking()
|
|
||||||
except Exception:
|
|
||||||
logger.warning("东方财富板块实时数据获取失败,回退到日级数据")
|
|
||||||
for s in sectors_data:
|
|
||||||
s["realtime_pct_change"] = None
|
|
||||||
s["realtime_limit_up_count"] = None
|
|
||||||
s["is_realtime"] = False
|
|
||||||
return sectors_data
|
|
||||||
|
|
||||||
if not em_sectors:
|
|
||||||
for s in sectors_data:
|
|
||||||
s["realtime_pct_change"] = None
|
|
||||||
s["realtime_limit_up_count"] = None
|
|
||||||
s["is_realtime"] = False
|
|
||||||
return sectors_data
|
|
||||||
|
|
||||||
# 构建东方财富板块名查找表(用于匹配)
|
|
||||||
em_name_map = {s["sector_name"]: s for s in em_sectors}
|
|
||||||
|
|
||||||
matched = 0
|
|
||||||
for s in sectors_data:
|
|
||||||
ts_name = s["sector_name"]
|
|
||||||
# 尝试匹配:先精确,再模糊
|
|
||||||
em_data = em_name_map.get(ts_name)
|
|
||||||
if not em_data:
|
|
||||||
# 模糊匹配
|
|
||||||
for em_s in em_sectors:
|
|
||||||
if _match_sector_name(em_s["sector_name"], ts_name):
|
|
||||||
em_data = em_s
|
|
||||||
break
|
|
||||||
|
|
||||||
if em_data:
|
|
||||||
matched += 1
|
|
||||||
s["realtime_pct_change"] = em_data["pct_change"]
|
|
||||||
s["is_realtime"] = True
|
|
||||||
# 涨停家数仍保留 Tushare 数据(东方财富此字段不可用)
|
|
||||||
s["realtime_limit_up_count"] = None
|
|
||||||
# 更新领涨股(东方财富直接提供)
|
|
||||||
if em_data.get("leading_stock_name"):
|
|
||||||
s["leading_stocks_realtime"] = [
|
|
||||||
{
|
|
||||||
"ts_code": em_data.get("leading_stock_code", ""),
|
|
||||||
"name": em_data.get("leading_stock_name", ""),
|
|
||||||
"pct_chg": em_data.get("leading_stock_pct", 0),
|
|
||||||
"amount": 0,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
s["realtime_pct_change"] = None
|
|
||||||
s["realtime_limit_up_count"] = None
|
|
||||||
s["is_realtime"] = False
|
|
||||||
|
|
||||||
logger.info(f"板块实时数据: {matched}/{len(sectors_data)} 匹配成功")
|
|
||||||
|
|
||||||
# 盘中按实时涨幅重新排序
|
|
||||||
sectors_data.sort(key=lambda s: s.get("realtime_pct_change") or s.get("pct_change") or 0, reverse=True)
|
|
||||||
|
|
||||||
return sectors_data
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hot")
|
@router.get("/hot")
|
||||||
async def get_hot_sectors(limit: int = 10):
|
async def get_hot_sectors(limit: int = 10):
|
||||||
"""获取热门板块排名(盘中自动补充实时数据)"""
|
"""获取热门板块排名(盘中自动补充实时数据)"""
|
||||||
sectors = await get_latest_sectors()
|
sectors = await get_latest_sectors()
|
||||||
|
sectors = await enrich_sectors_with_realtime(sectors)
|
||||||
|
trade_date = sectors[0].trade_date if sectors else ""
|
||||||
|
|
||||||
sectors_data = [
|
sectors_data = [
|
||||||
{
|
{
|
||||||
"sector_code": s.sector_code,
|
"sector_code": s.sector_code,
|
||||||
@ -125,11 +33,25 @@ async def get_hot_sectors(limit: int = 10):
|
|||||||
"pct_trend": s.pct_trend,
|
"pct_trend": s.pct_trend,
|
||||||
"turnover_avg": s.turnover_avg,
|
"turnover_avg": s.turnover_avg,
|
||||||
"main_force_ratio": s.main_force_ratio,
|
"main_force_ratio": s.main_force_ratio,
|
||||||
|
"trade_date": trade_date,
|
||||||
|
"realtime_pct_change": s.realtime_pct_change,
|
||||||
|
"realtime_limit_up_count": s.realtime_limit_up_count,
|
||||||
|
"realtime_amount": s.realtime_amount,
|
||||||
|
"realtime_turnover_rate": s.realtime_turnover_rate,
|
||||||
|
"realtime_up_count": s.realtime_up_count,
|
||||||
|
"realtime_down_count": s.realtime_down_count,
|
||||||
|
"leading_stocks_realtime": s.leading_stocks_realtime,
|
||||||
|
"is_realtime": s.is_realtime,
|
||||||
|
"data_mode": s.data_mode,
|
||||||
}
|
}
|
||||||
for s in sectors[:limit]
|
for s in sectors[:limit]
|
||||||
]
|
]
|
||||||
|
|
||||||
sectors_data = await _enrich_sectors_realtime(sectors_data)
|
realtime_enabled = any(s.get("is_realtime") for s in sectors_data)
|
||||||
|
mode = "realtime_overlay" if realtime_enabled else "daily_snapshot"
|
||||||
|
for s in sectors_data:
|
||||||
|
s["data_mode"] = mode
|
||||||
|
s["structure_trade_date"] = trade_date
|
||||||
return sectors_data
|
return sectors_data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -513,10 +513,10 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
mode_instruction_map = {
|
mode_instruction_map = {
|
||||||
"entry": "这是建仓前诊断。重点判断是否值得纳入操作或重点关注,强调触发条件和失效条件。",
|
"entry": "这是建仓前诊断。必须明确当前是可操作、重点关注、观察还是回避,并给出触发与失效边界。",
|
||||||
"holding": "这是持仓复核。重点判断原有逻辑是否仍成立,是否该继续持有、减仓或退出。",
|
"holding": "这是持仓复核。必须回答逻辑是否还成立,当前更适合持有、减仓、退出还是继续观察。",
|
||||||
"review": "这是回撤复盘。重点分析问题出在个股、板块还是市场环境,并给出修正建议。",
|
"review": "这是回撤复盘。重点拆清问题来自市场、板块还是个股执行,并提出下一轮修正动作。",
|
||||||
"tracking": "这是继续跟踪。重点判断是否保留在观察池、何时升级为可操作或何时移除。",
|
"tracking": "这是继续跟踪。必须说明保留理由、升级条件和移除条件,避免空泛表述。",
|
||||||
}
|
}
|
||||||
mode_label_map = {
|
mode_label_map = {
|
||||||
"entry": "建仓前诊断",
|
"entry": "建仓前诊断",
|
||||||
@ -551,6 +551,8 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")):
|
|||||||
3. 趋势评分是推荐体系的技术面核心分数(均线排列40+高低点结构35+MA20方向25=满分100),辅助信号计数仅供参考不参与主评分。
|
3. 趋势评分是推荐体系的技术面核心分数(均线排列40+高低点结构35+MA20方向25=满分100),辅助信号计数仅供参考不参与主评分。
|
||||||
4. 位置安全评分高(>80)表示股价处于相对低位,低(<40)表示可能追高。
|
4. 位置安全评分高(>80)表示股价处于相对低位,低(<40)表示可能追高。
|
||||||
5. 板块信息和推荐体系信息优先级高于单一技术指标。
|
5. 板块信息和推荐体系信息优先级高于单一技术指标。
|
||||||
|
6. 先给结论和动作,再解释原因;不要先铺陈背景再拖到最后才下结论。
|
||||||
|
7. 如果证据不足,也要明确给出“观察”或“回避”,不能写成模糊建议。
|
||||||
{freshness_note}
|
{freshness_note}
|
||||||
|
|
||||||
请严格按以下 Markdown 结构输出,不要写成泛泛长文:
|
请严格按以下 Markdown 结构输出,不要写成泛泛长文:
|
||||||
@ -558,18 +560,21 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")):
|
|||||||
## 当前结论
|
## 当前结论
|
||||||
- 结论: 只能从「可操作 / 重点关注 / 观察 / 回避」中选一个
|
- 结论: 只能从「可操作 / 重点关注 / 观察 / 回避」中选一个
|
||||||
- 一句话判断: 用一句话解释为什么
|
- 一句话判断: 用一句话解释为什么
|
||||||
|
- 当前动作: 只能从「执行 / 等确认 / 继续跟踪 / 暂不参与」中选一个
|
||||||
- 适配模式: 说明更适合启动试错、分歧回流、趋势跟随还是只观察
|
- 适配模式: 说明更适合启动试错、分歧回流、趋势跟随还是只观察
|
||||||
|
|
||||||
## 核心逻辑
|
## 核心逻辑
|
||||||
- 市场环境: 当前大盘和风格是否支持这只票
|
- 市场环境: 当前大盘和风格是否支持这只票
|
||||||
- 板块位置: 所属板块是主线、次主线还是观察线
|
- 板块位置: 所属板块是主线、次主线还是观察线
|
||||||
- 个股角色: 龙头 / 跟风 / 独立逻辑 / 非核心
|
- 个股角色: 龙头 / 跟风 / 独立逻辑 / 非核心
|
||||||
|
- 关键证据: 只提最重要的两到三条证据,不要抄原始数据
|
||||||
|
|
||||||
## 执行动作
|
## 执行动作
|
||||||
- 触发条件: 什么情况下才可以行动
|
- 触发条件: 什么情况下才可以行动
|
||||||
- 失效条件: 什么情况下放弃
|
- 失效条件: 什么情况下放弃
|
||||||
- 仓位建议: 用低 / 中 / 高 或百分比表达
|
- 仓位建议: 用低 / 中 / 高 或百分比表达
|
||||||
- 适合谁: 适合激进试错、低吸等待、还是不适合参与
|
- 适合谁: 适合激进试错、低吸等待、还是不适合参与
|
||||||
|
- 跟踪重点: 下一交易时段最该盯住什么
|
||||||
|
|
||||||
## 风险清单
|
## 风险清单
|
||||||
- 风险1:
|
- 风险1:
|
||||||
@ -579,11 +584,15 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")):
|
|||||||
## 复盘问题
|
## 复盘问题
|
||||||
- 如果后续走势不符合预期,优先检查哪两个问题
|
- 如果后续走势不符合预期,优先检查哪两个问题
|
||||||
|
|
||||||
|
## 会诊纪要
|
||||||
|
- 用两到三句话总结本次会诊,不要写成长文,不要复制前面的条目
|
||||||
|
|
||||||
要求:
|
要求:
|
||||||
- 结论必须明确,不能模糊两可
|
- 结论必须明确,不能模糊两可
|
||||||
- 少写形容词,多写交易判断
|
- 少写形容词,多写交易判断
|
||||||
- 不要重复原始数据
|
- 不要重复原始数据
|
||||||
- 文字保持简洁,避免旧式研报语气"""
|
- 文字保持简洁,避免旧式研报语气
|
||||||
|
- 每个条目尽量一句话说清,不要堆砌长段落"""
|
||||||
|
|
||||||
# ── SSE 流式返回 ──
|
# ── SSE 流式返回 ──
|
||||||
async def _stream_diagnosis():
|
async def _stream_diagnosis():
|
||||||
@ -593,7 +602,7 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")):
|
|||||||
stream = await client.chat.completions.create(
|
stream = await client.chat.completions.create(
|
||||||
model=settings.deepseek_model,
|
model=settings.deepseek_model,
|
||||||
messages=[
|
messages=[
|
||||||
{"role": "system", "content": "你是A股AI投研作战台中的个股会诊模块。你的职责不是写传统长文研报,而是基于市场环境、板块地位、推荐体系评分和跟踪结果,输出可执行、结构化的交易会诊意见。回复必须使用Markdown,结论明确,强调触发条件、失效条件、仓位和风险。"},
|
{"role": "system", "content": "你是A股AI投研作战台中的个股会诊模块。你输出的是交易会诊单,不是传统研报。必须先给明确结论,再给执行动作、风险边界和跟踪重点。回复必须使用Markdown,结构严格、结论清晰、语言简短,禁止空泛抒情。"},
|
||||||
{"role": "user", "content": user_msg},
|
{"role": "user", "content": user_msg},
|
||||||
],
|
],
|
||||||
max_tokens=1500,
|
max_tokens=1500,
|
||||||
|
|||||||
@ -105,3 +105,27 @@ def is_pre_close() -> bool:
|
|||||||
now = datetime.now(ZoneInfo("Asia/Shanghai"))
|
now = datetime.now(ZoneInfo("Asia/Shanghai"))
|
||||||
t = now.hour * 100 + now.minute
|
t = now.hour * 100 + now.minute
|
||||||
return 1500 <= t <= 1530
|
return 1500 <= t <= 1530
|
||||||
|
|
||||||
|
|
||||||
|
def today_trade_date() -> str:
|
||||||
|
"""返回上海时区下的今天日期(YYYYMMDD)。"""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
return datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
|
||||||
|
|
||||||
|
|
||||||
|
def should_prefer_realtime_today(latest_trade_date: str | None = None) -> bool:
|
||||||
|
"""是否应该优先使用“今天”的实时数据。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 非交易日直接 False
|
||||||
|
2. 交易日内(含午休、收盘后)如果 Tushare 最新交易日还不是今天,则优先实时
|
||||||
|
3. 即便 Tushare 最新交易日已经是今天,只要仍处于 15:30 前的延迟窗口,也优先实时
|
||||||
|
"""
|
||||||
|
if not is_market_session() and not is_pre_close():
|
||||||
|
return False
|
||||||
|
|
||||||
|
today = today_trade_date()
|
||||||
|
if latest_trade_date and latest_trade_date != today:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return is_market_session() or is_pre_close()
|
||||||
|
|||||||
Binary file not shown.
@ -56,6 +56,7 @@ class CapitalFlow(BaseModel):
|
|||||||
class SectorInfo(BaseModel):
|
class SectorInfo(BaseModel):
|
||||||
sector_code: str
|
sector_code: str
|
||||||
sector_name: str
|
sector_name: str
|
||||||
|
trade_date: str = ""
|
||||||
pct_change: float = 0 # 涨跌幅 %
|
pct_change: float = 0 # 涨跌幅 %
|
||||||
capital_inflow: float = 0 # 主力净流入(万元,原始数据来自Tushare亿元×10000)
|
capital_inflow: float = 0 # 主力净流入(万元,原始数据来自Tushare亿元×10000)
|
||||||
limit_up_count: int = 0 # 涨停数
|
limit_up_count: int = 0 # 涨停数
|
||||||
@ -70,6 +71,15 @@ class SectorInfo(BaseModel):
|
|||||||
pct_trend: list[float] = [] # 近5日涨跌幅趋势
|
pct_trend: list[float] = [] # 近5日涨跌幅趋势
|
||||||
turnover_avg: float = 0 # 板块平均换手率
|
turnover_avg: float = 0 # 板块平均换手率
|
||||||
main_force_ratio: float = 0 # 主力资金占比(主力净流入/总成交额)
|
main_force_ratio: float = 0 # 主力资金占比(主力净流入/总成交额)
|
||||||
|
realtime_pct_change: float | None = None
|
||||||
|
realtime_limit_up_count: int | None = None
|
||||||
|
realtime_amount: float | None = None
|
||||||
|
realtime_turnover_rate: float | None = None
|
||||||
|
realtime_up_count: int | None = None
|
||||||
|
realtime_down_count: int | None = None
|
||||||
|
leading_stocks_realtime: list[dict] = []
|
||||||
|
is_realtime: bool = False
|
||||||
|
data_mode: str = "daily_snapshot"
|
||||||
|
|
||||||
|
|
||||||
class MarketTemperature(BaseModel):
|
class MarketTemperature(BaseModel):
|
||||||
@ -157,11 +167,16 @@ class StrategySectorFocus(BaseModel):
|
|||||||
heat_score: float = 0
|
heat_score: float = 0
|
||||||
pct_change: float = 0
|
pct_change: float = 0
|
||||||
limit_up_count: int = 0
|
limit_up_count: int = 0
|
||||||
|
turnover_rate: float = 0
|
||||||
|
up_count: int = 0
|
||||||
|
down_count: int = 0
|
||||||
|
data_mode: str = "daily_snapshot"
|
||||||
view: str = ""
|
view: str = ""
|
||||||
|
|
||||||
|
|
||||||
class StrategyBoard(BaseModel):
|
class StrategyBoard(BaseModel):
|
||||||
trade_date: str
|
trade_date: str
|
||||||
|
data_mode: str = "daily_snapshot"
|
||||||
market_regime: str
|
market_regime: str
|
||||||
risk_level: str
|
risk_level: str
|
||||||
action_bias: str
|
action_bias: str
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -817,6 +817,7 @@ async def _load_sectors_from_db() -> list[SectorInfo]:
|
|||||||
sectors.append(SectorInfo(
|
sectors.append(SectorInfo(
|
||||||
sector_code=r["sector_code"],
|
sector_code=r["sector_code"],
|
||||||
sector_name=r["sector_name"],
|
sector_name=r["sector_name"],
|
||||||
|
trade_date=r.get("trade_date") or "",
|
||||||
pct_change=r["pct_change"] or 0,
|
pct_change=r["pct_change"] or 0,
|
||||||
capital_inflow=r["capital_inflow"] or 0,
|
capital_inflow=r["capital_inflow"] or 0,
|
||||||
limit_up_count=r["limit_up_count"] or 0,
|
limit_up_count=r["limit_up_count"] or 0,
|
||||||
|
|||||||
@ -29,7 +29,8 @@ from app.analysis.trend_scanner import scan_trend_breakout
|
|||||||
from app.analysis.signals import generate_signals
|
from app.analysis.signals import generate_signals
|
||||||
from app.analysis.intraday import intraday_market_temperature, intraday_filter_stocks, intraday_sector_scan
|
from app.analysis.intraday import intraday_market_temperature, intraday_filter_stocks, intraday_sector_scan
|
||||||
from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Recommendation
|
from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Recommendation
|
||||||
from app.config import settings, is_trading_hours, is_market_session
|
from app.config import settings, is_trading_hours, is_market_session, should_prefer_realtime_today
|
||||||
|
from app.data.tushare_client import tushare_client
|
||||||
from app.llm.strategy_selector import select_strategy_profile
|
from app.llm.strategy_selector import select_strategy_profile
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -45,7 +46,8 @@ async def run_screening(trade_date: str = None) -> dict:
|
|||||||
"scan_mode": "intraday" | "post_market",
|
"scan_mode": "intraday" | "post_market",
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
intraday = is_market_session()
|
latest_trade_date = tushare_client.get_latest_trade_date()
|
||||||
|
intraday = should_prefer_realtime_today(latest_trade_date)
|
||||||
scan_mode = "intraday" if intraday else "post_market"
|
scan_mode = "intraday" if intraday else "post_market"
|
||||||
logger.info(f"=== 筛选模式: {'盘中实时' if intraday else '盘后'} ===")
|
logger.info(f"=== 筛选模式: {'盘中实时' if intraday else '盘后'} ===")
|
||||||
|
|
||||||
@ -695,32 +697,60 @@ async def _build_recommendations(
|
|||||||
)
|
)
|
||||||
llm_results = await analyze_candidates_individually(llm_top, market_summary)
|
llm_results = await analyze_candidates_individually(llm_top, market_summary)
|
||||||
|
|
||||||
# 综合量化 + LLM 判断
|
# 综合规则边界 + LLM 最终裁决
|
||||||
for rec in recommendations:
|
for rec in recommendations:
|
||||||
llm_data = llm_results.get(rec.ts_code)
|
llm_data = llm_results.get(rec.ts_code)
|
||||||
if llm_data:
|
if llm_data:
|
||||||
rec.llm_analysis = llm_data.get("analysis", "")
|
rec.llm_analysis = llm_data.get("analysis", "")
|
||||||
|
rec.llm_score = float(llm_data.get("conviction", 0) or 0)
|
||||||
|
|
||||||
# LLM 信号强度转换为分数调整
|
verdict = llm_data.get("verdict", "watch")
|
||||||
# 调整幅度温和,保底不低于 60,避免推荐被过滤掉
|
action_plan = llm_data.get("action_plan", "")
|
||||||
strength = llm_data.get("strength", "中")
|
conviction = float(llm_data.get("conviction", 6) or 6)
|
||||||
llm_signal = llm_data.get("signal", "HOLD")
|
ai_score = conviction * 10
|
||||||
|
|
||||||
if llm_signal == "SKIP":
|
if verdict == "execute":
|
||||||
# 降分但保底 60,排序靠后但不消失
|
rec.score = round(rec.score * 0.4 + ai_score * 0.6 + 4, 1)
|
||||||
rec.score = max(60, round(rec.score - 15, 1))
|
elif verdict == "watch":
|
||||||
elif llm_signal == "HOLD":
|
rec.score = round(rec.score * 0.5 + ai_score * 0.5 - 2, 1)
|
||||||
rec.score = max(60, round(rec.score - 5, 1))
|
else: # skip
|
||||||
elif llm_signal == "BUY" and strength == "强":
|
rec.score = round(rec.score * 0.45 + ai_score * 0.35 - 18, 1)
|
||||||
rec.score = round(rec.score + 10, 1)
|
|
||||||
elif llm_signal == "BUY" and strength == "中":
|
if verdict == "skip":
|
||||||
rec.score = round(rec.score + 5, 1)
|
rec.signal = "HOLD"
|
||||||
else: # BUY + 弱
|
rec.action_plan = "观察"
|
||||||
pass # 不调整
|
rec.lifecycle_status = "candidate"
|
||||||
|
if not rec.risk_note:
|
||||||
|
rec.risk_note = llm_data.get("risk_flag", "") or rec.risk_note
|
||||||
|
else:
|
||||||
|
if action_plan in {"可操作", "重点关注", "观察"}:
|
||||||
|
rec.action_plan = action_plan
|
||||||
|
elif verdict == "execute":
|
||||||
|
rec.action_plan = "可操作"
|
||||||
|
else:
|
||||||
|
rec.action_plan = "重点关注"
|
||||||
|
|
||||||
|
rec.signal = "BUY" if verdict == "execute" else "HOLD"
|
||||||
|
if rec.action_plan == "可操作":
|
||||||
|
rec.lifecycle_status = "actionable"
|
||||||
|
elif rec.action_plan == "重点关注":
|
||||||
|
rec.lifecycle_status = "candidate"
|
||||||
|
|
||||||
|
if llm_data.get("timing"):
|
||||||
|
rec.entry_timing = llm_data["timing"]
|
||||||
|
|
||||||
|
if llm_data.get("trigger_condition"):
|
||||||
|
rec.trigger_condition = llm_data["trigger_condition"]
|
||||||
|
if llm_data.get("invalidation_condition"):
|
||||||
|
rec.invalidation_condition = llm_data["invalidation_condition"]
|
||||||
|
if llm_data.get("position_pct") is not None:
|
||||||
|
rec.suggested_position_pct = float(llm_data["position_pct"] or 0)
|
||||||
|
if llm_data.get("risk_flag"):
|
||||||
|
rec.risk_note = llm_data["risk_flag"]
|
||||||
|
|
||||||
rec.level = _score_to_level(rec.score)
|
rec.level = _score_to_level(rec.score)
|
||||||
|
|
||||||
# 用 LLM 给出的价格替代硬编码价格
|
# 用 LLM 给出的价格替代结构化规则价格
|
||||||
if llm_data.get("entry_price"):
|
if llm_data.get("entry_price"):
|
||||||
rec.entry_price = llm_data["entry_price"]
|
rec.entry_price = llm_data["entry_price"]
|
||||||
if llm_data.get("target_price"):
|
if llm_data.get("target_price"):
|
||||||
@ -728,6 +758,11 @@ async def _build_recommendations(
|
|||||||
if llm_data.get("stop_loss"):
|
if llm_data.get("stop_loss"):
|
||||||
rec.stop_loss = llm_data["stop_loss"]
|
rec.stop_loss = llm_data["stop_loss"]
|
||||||
|
|
||||||
|
# LLM 明确 skip 的标的,从推荐前列剔除
|
||||||
|
recommendations = [
|
||||||
|
rec for rec in recommendations
|
||||||
|
if not (rec.llm_score is not None and rec.llm_score <= 4 and rec.action_plan == "观察" and rec.score < strategy_profile.min_score)
|
||||||
|
]
|
||||||
recommendations.sort(key=lambda r: r.score, reverse=True)
|
recommendations.sort(key=lambda r: r.score, reverse=True)
|
||||||
recommendations = recommendations[:settings.top_stock_count]
|
recommendations = recommendations[:settings.top_stock_count]
|
||||||
logger.info(f"LLM 逐股分析完成, 综合评分后保留 {len(recommendations)} 只")
|
logger.info(f"LLM 逐股分析完成, 综合评分后保留 {len(recommendations)} 只")
|
||||||
|
|||||||
Binary file not shown.
@ -5,6 +5,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@ -21,11 +22,17 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict:
|
|||||||
market_summary: 市场环境摘要
|
market_summary: 市场环境摘要
|
||||||
|
|
||||||
返回: {
|
返回: {
|
||||||
"signal": "BUY"/"HOLD"/"SKIP",
|
"verdict": "execute"/"watch"/"skip",
|
||||||
"strength": "强"/"中"/"弱",
|
"action_plan": "可操作"/"重点关注"/"观察",
|
||||||
|
"conviction": int,
|
||||||
|
"timing": str,
|
||||||
"entry_price": float or None,
|
"entry_price": float or None,
|
||||||
"target_price": float or None,
|
"target_price": float or None,
|
||||||
"stop_loss": float or None,
|
"stop_loss": float or None,
|
||||||
|
"trigger_condition": str,
|
||||||
|
"invalidation_condition": str,
|
||||||
|
"position_pct": int,
|
||||||
|
"risk_flag": str,
|
||||||
"analysis": str,
|
"analysis": str,
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
@ -36,7 +43,7 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict:
|
|||||||
stock_text = f"""\
|
stock_text = f"""\
|
||||||
股票: {candidate['name']}({candidate['ts_code']})
|
股票: {candidate['name']}({candidate['ts_code']})
|
||||||
板块: {candidate.get('sector', '未知')}
|
板块: {candidate.get('sector', '未知')}
|
||||||
量化评分: {candidate.get('quant_score', 0)}/100
|
规则参考分: {candidate.get('quant_score', 0)}/100
|
||||||
位置安全: {candidate.get('position_score', 50)}/100
|
位置安全: {candidate.get('position_score', 50)}/100
|
||||||
当前价: {candidate.get('current_price', '未知')}"""
|
当前价: {candidate.get('current_price', '未知')}"""
|
||||||
|
|
||||||
@ -56,9 +63,10 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict:
|
|||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": (
|
"content": (
|
||||||
"你是一位专业的A股趋势交易分析师,专注于中短线(1-5日)交易。"
|
"你是一位A股短线交易裁决员。"
|
||||||
"你根据技术分析结论独立判断入场时机,给出具体的买卖价格建议。"
|
"你的任务是决定这只股票今天是否该进入推荐前列,以及应该归入可操作、重点关注还是观察。"
|
||||||
"不要被量化评分束缚,给出你真实的判断。"
|
"不要复述数据,不要写成长文,不要被规则参考分绑架。"
|
||||||
|
"必须返回合法JSON。"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{"role": "user", "content": user_msg},
|
{"role": "user", "content": user_msg},
|
||||||
@ -72,69 +80,131 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"LLM 分析 {candidate.get('ts_code')} 失败: {e}")
|
logger.error(f"LLM 分析 {candidate.get('ts_code')} 失败: {e}")
|
||||||
return {
|
return {
|
||||||
"signal": "HOLD",
|
"verdict": "watch",
|
||||||
"strength": "弱",
|
"action_plan": "重点关注",
|
||||||
|
"conviction": 4,
|
||||||
|
"timing": "",
|
||||||
"entry_price": None,
|
"entry_price": None,
|
||||||
"target_price": None,
|
"target_price": None,
|
||||||
"stop_loss": None,
|
"stop_loss": None,
|
||||||
|
"trigger_condition": "",
|
||||||
|
"invalidation_condition": "",
|
||||||
|
"position_pct": 0,
|
||||||
|
"risk_flag": "AI 裁决暂不可用",
|
||||||
"analysis": "AI分析暂不可用",
|
"analysis": "AI分析暂不可用",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _parse_single_response(text: str) -> dict:
|
def _parse_single_response(text: str) -> dict:
|
||||||
"""解析单只股票的 LLM 返回"""
|
"""解析单只股票的 LLM 返回"""
|
||||||
# 提取信号
|
data = _extract_json_object(text)
|
||||||
|
if data:
|
||||||
|
verdict = str(data.get("verdict", "watch")).strip().lower()
|
||||||
|
if verdict not in {"execute", "watch", "skip"}:
|
||||||
|
verdict = "watch"
|
||||||
|
|
||||||
|
action_plan = str(data.get("action_plan", "")).strip()
|
||||||
|
if action_plan not in {"可操作", "重点关注", "观察"}:
|
||||||
|
action_plan = {"execute": "可操作", "watch": "重点关注", "skip": "观察"}[verdict]
|
||||||
|
|
||||||
|
conviction = _clamp_int(data.get("conviction"), minimum=1, maximum=10, default=6)
|
||||||
|
position_pct = _clamp_int(data.get("position_pct"), minimum=0, maximum=35, default=0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"verdict": verdict,
|
||||||
|
"action_plan": action_plan,
|
||||||
|
"conviction": conviction,
|
||||||
|
"timing": str(data.get("timing", "")).strip(),
|
||||||
|
"entry_price": _parse_float(data.get("entry_price")),
|
||||||
|
"target_price": _parse_float(data.get("target_price")),
|
||||||
|
"stop_loss": _parse_float(data.get("stop_loss")),
|
||||||
|
"trigger_condition": str(data.get("trigger_condition", "")).strip(),
|
||||||
|
"invalidation_condition": str(data.get("invalidation_condition", "")).strip(),
|
||||||
|
"position_pct": position_pct,
|
||||||
|
"analysis": str(data.get("analysis", "")).strip() or "暂无分析",
|
||||||
|
"risk_flag": str(data.get("risk_flag", "")).strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 兼容旧格式
|
||||||
signal = "HOLD"
|
signal = "HOLD"
|
||||||
signal_match = re.search(r"信号[:\s]*(BUY|HOLD|SKIP)", text)
|
signal_match = re.search(r"信号[:\s]*(BUY|HOLD|SKIP)", text)
|
||||||
if signal_match:
|
if signal_match:
|
||||||
signal = signal_match.group(1)
|
signal = signal_match.group(1)
|
||||||
|
|
||||||
# 提取信号强度
|
verdict = "execute" if signal == "BUY" else "skip" if signal == "SKIP" else "watch"
|
||||||
strength = "中"
|
strength = "中"
|
||||||
strength_match = re.search(r"信号强度[:\s]*(强|中|弱)", text)
|
strength_match = re.search(r"信号强度[:\s]*(强|中|弱)", text)
|
||||||
if strength_match:
|
if strength_match:
|
||||||
strength = strength_match.group(1)
|
strength = strength_match.group(1)
|
||||||
|
conviction = {"强": 8, "中": 6, "弱": 4}.get(strength, 6)
|
||||||
|
|
||||||
# 提取买入价
|
|
||||||
entry_price = None
|
entry_price = None
|
||||||
entry_match = re.search(r"买入价[:\s]*(\d+(?:\.\d+)?)", text)
|
entry_match = re.search(r"买入价[:\s]*(\d+(?:\.\d+)?)", text)
|
||||||
if entry_match:
|
if entry_match:
|
||||||
entry_price = float(entry_match.group(1))
|
entry_price = float(entry_match.group(1))
|
||||||
|
|
||||||
# 提取止盈价
|
|
||||||
target_price = None
|
target_price = None
|
||||||
target_match = re.search(r"止盈价[:\s]*(\d+(?:\.\d+)?)", text)
|
target_match = re.search(r"止盈价[:\s]*(\d+(?:\.\d+)?)", text)
|
||||||
if target_match:
|
if target_match:
|
||||||
target_price = float(target_match.group(1))
|
target_price = float(target_match.group(1))
|
||||||
|
|
||||||
# 提取止损价
|
|
||||||
stop_loss = None
|
stop_loss = None
|
||||||
stop_match = re.search(r"止损价[:\s]*(\d+(?:\.\d+)?)", text)
|
stop_match = re.search(r"止损价[:\s]*(\d+(?:\.\d+)?)", text)
|
||||||
if stop_match:
|
if stop_match:
|
||||||
stop_loss = float(stop_match.group(1))
|
stop_loss = float(stop_match.group(1))
|
||||||
|
|
||||||
# 提取分析
|
|
||||||
analysis = ""
|
analysis = ""
|
||||||
analysis_match = re.search(r"分析[:\s]*(.+)", text, re.DOTALL)
|
analysis_match = re.search(r"分析[:\s]*(.+)", text, re.DOTALL)
|
||||||
if analysis_match:
|
if analysis_match:
|
||||||
analysis = analysis_match.group(1).strip()
|
analysis = analysis_match.group(1).strip()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"signal": signal,
|
"verdict": verdict,
|
||||||
"strength": strength,
|
"action_plan": {"execute": "可操作", "watch": "重点关注", "skip": "观察"}[verdict],
|
||||||
|
"conviction": conviction,
|
||||||
|
"timing": "",
|
||||||
"entry_price": entry_price,
|
"entry_price": entry_price,
|
||||||
"target_price": target_price,
|
"target_price": target_price,
|
||||||
"stop_loss": stop_loss,
|
"stop_loss": stop_loss,
|
||||||
|
"trigger_condition": "",
|
||||||
|
"invalidation_condition": "",
|
||||||
|
"position_pct": 20 if verdict == "execute" else 0,
|
||||||
"analysis": analysis or "暂无分析",
|
"analysis": analysis or "暂无分析",
|
||||||
|
"risk_flag": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_object(text: str) -> dict | None:
|
||||||
|
match = re.search(r"\{[\s\S]*\}", text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(match.group(0))
|
||||||
|
return parsed if isinstance(parsed, dict) else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_float(value) -> float | None:
|
||||||
|
try:
|
||||||
|
if value in (None, "", 0, "0"):
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_int(value, minimum: int, maximum: int, default: int) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(round(float(value)))
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
return max(minimum, min(maximum, parsed))
|
||||||
|
|
||||||
|
|
||||||
async def analyze_candidates_individually(
|
async def analyze_candidates_individually(
|
||||||
candidates: list[dict], market_summary: str, max_concurrent: int = 3
|
candidates: list[dict], market_summary: str, max_concurrent: int = 3
|
||||||
) -> dict[str, dict]:
|
) -> dict[str, dict]:
|
||||||
"""对候选股票逐个做 LLM 分析(控制并发数)
|
"""对候选股票逐个做 LLM 分析(控制并发数)
|
||||||
|
|
||||||
返回: {ts_code: {"signal", "strength", "entry_price", ...}}
|
返回: {ts_code: {"verdict", "action_plan", "conviction", "entry_price", ...}}
|
||||||
"""
|
"""
|
||||||
if not settings.deepseek_api_key or not candidates:
|
if not settings.deepseek_api_key or not candidates:
|
||||||
return {}
|
return {}
|
||||||
@ -149,7 +219,7 @@ async def analyze_candidates_individually(
|
|||||||
result = await analyze_single_stock(c, market_summary)
|
result = await analyze_single_stock(c, market_summary)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"LLM 结果: {c.get('name', ts_code)} → "
|
f"LLM 结果: {c.get('name', ts_code)} → "
|
||||||
f"信号={result['signal']} 强度={result['strength']} "
|
f"裁决={result['verdict']} 计划={result['action_plan']} 置信度={result['conviction']} "
|
||||||
f"买入={result.get('entry_price')} 止盈={result.get('target_price')} "
|
f"买入={result.get('entry_price')} 止盈={result.get('target_price')} "
|
||||||
f"止损={result.get('stop_loss')}"
|
f"止损={result.get('stop_loss')}"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings, today_trade_date
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -13,7 +13,8 @@ async def generate_review() -> dict:
|
|||||||
from app.data import tencent_client
|
from app.data import tencent_client
|
||||||
from app.engine.recommender import get_latest_recommendations, get_latest_sectors
|
from app.engine.recommender import get_latest_recommendations, get_latest_sectors
|
||||||
|
|
||||||
trade_date = tushare_client.get_latest_trade_date()
|
latest_trade_date = tushare_client.get_latest_trade_date()
|
||||||
|
trade_date = today_trade_date()
|
||||||
|
|
||||||
# 收集市场数据
|
# 收集市场数据
|
||||||
result = await get_latest_recommendations()
|
result = await get_latest_recommendations()
|
||||||
@ -45,7 +46,7 @@ async def generate_review() -> dict:
|
|||||||
index_summary += f"{name_map[code]}: {d.get('price', 0):.2f} ({d.get('pct_chg', 0):+.2f}%), "
|
index_summary += f"{name_map[code]}: {d.get('price', 0):.2f} ({d.get('pct_chg', 0):+.2f}%), "
|
||||||
|
|
||||||
sector_summary = "热门板块: " + "、".join(
|
sector_summary = "热门板块: " + "、".join(
|
||||||
f"{s.sector_name}({s.pct_change:+.1f}%)" for s in sectors[:5]
|
f"{s.sector_name}({(getattr(s, 'realtime_pct_change', None) or s.pct_change):+.1f}%)" for s in sectors[:5]
|
||||||
)
|
)
|
||||||
|
|
||||||
rec_summary = ""
|
rec_summary = ""
|
||||||
@ -58,6 +59,7 @@ async def generate_review() -> dict:
|
|||||||
user_msg = f"""请根据以下数据生成今日A股市场复盘报告(中文):
|
user_msg = f"""请根据以下数据生成今日A股市场复盘报告(中文):
|
||||||
|
|
||||||
日期: {trade_date}
|
日期: {trade_date}
|
||||||
|
Tushare 最新交易日: {latest_trade_date}
|
||||||
{market_summary}
|
{market_summary}
|
||||||
{index_summary}
|
{index_summary}
|
||||||
{sector_summary}
|
{sector_summary}
|
||||||
|
|||||||
@ -122,32 +122,37 @@ ANALYSIS_USER_TEMPLATE = """\
|
|||||||
# ── AI 逐股筛选 Prompt ──
|
# ── AI 逐股筛选 Prompt ──
|
||||||
|
|
||||||
SINGLE_STOCK_ANALYSIS_PROMPT = """\
|
SINGLE_STOCK_ANALYSIS_PROMPT = """\
|
||||||
你是一位专业的A股趋势交易分析师,专注于中短线交易(持仓1-5个交易日)。
|
你是一位A股中短线交易裁决员,不是量化打分解释器。你的职责是基于给定的市场、板块、量价和位置结论,决定这只股票今天应不应该进入推荐池前列,以及应该归入什么动作级别。
|
||||||
|
|
||||||
量化系统已通过多轮筛选认定该股票具备投资价值,请你基于以下技术分析结论,独立判断入场时机。
|
你的原则:
|
||||||
|
1. 量化分数只是参考,不是最终答案
|
||||||
|
2. 如果板块地位、量价质量、位置或时机不匹配,可以直接否决高分股
|
||||||
|
3. 如果股票具备明确触发与失效边界,即使量化分不是最高,也可以提升优先级
|
||||||
|
4. 输出的是交易裁决单,不是研报
|
||||||
|
|
||||||
你的任务:
|
请严格输出 JSON,不要输出 Markdown,不要添加多余解释。字段如下:
|
||||||
1. 综合趋势、量价、技术指标和位置,判断当前是否适合介入
|
{
|
||||||
2. 如果适合介入,给出具体的买入价位、止盈价和止损价
|
"verdict": "execute | watch | skip",
|
||||||
3. 评估信号强度
|
"action_plan": "可操作 | 重点关注 | 观察",
|
||||||
|
"conviction": 1-10,
|
||||||
|
"timing": "一句话描述当下最合适的处理方式",
|
||||||
|
"entry_price": 0,
|
||||||
|
"target_price": 0,
|
||||||
|
"stop_loss": 0,
|
||||||
|
"trigger_condition": "一句话",
|
||||||
|
"invalidation_condition": "一句话",
|
||||||
|
"position_pct": 0,
|
||||||
|
"analysis": "2-4句话,说明为什么给这个裁决",
|
||||||
|
"risk_flag": "一句话说明最大风险"
|
||||||
|
}
|
||||||
|
|
||||||
注意:
|
裁决标准:
|
||||||
- 你看到的是量化系统对K线和技术指标的分析结论,不是原始数据
|
- execute: 今天具备执行条件或非常接近执行条件,可以进入推荐前列
|
||||||
- 请独立判断,不要被量化评分影响太多
|
- watch: 逻辑还在,但需要等待确认,不应直接作为首选执行标的
|
||||||
- 止盈空间通常3-8%,止损空间通常3-5%
|
- skip: 当前不适合进入推荐前列,宁可错过也不主动参与
|
||||||
- 中短线交易,重点关注1-5日的走势
|
|
||||||
|
|
||||||
请严格按以下格式输出:
|
补充要求:
|
||||||
|
- conviction 必须是 1-10 的整数
|
||||||
信号: BUY/HOLD/SKIP
|
- position_pct 返回 0-35 的整数;如果不适合参与,就返回 0
|
||||||
信号强度: 强/中/弱
|
- 没有把握时优先给 watch 或 skip
|
||||||
买入价: XX.XX(建议入场价位,基于当前价微调)
|
- trigger_condition 和 invalidation_condition 必须可执行,不能写空话"""
|
||||||
止盈价: XX.XX(目标价位,给出合理空间)
|
|
||||||
止损价: XX.XX(跌破此价离场)
|
|
||||||
分析: 3-5句话,说明核心逻辑、入场理由和主要风险
|
|
||||||
|
|
||||||
说明:
|
|
||||||
- BUY: 看好,建议在买入价附近介入
|
|
||||||
- HOLD: 观望,等待更好的时机
|
|
||||||
- SKIP: 不看好,风险大于收益
|
|
||||||
- 信号强度"强"表示把握较大,"弱"表示不确定性较高"""
|
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.config import settings
|
from app.analysis.sector_realtime import enrich_sectors_with_realtime
|
||||||
|
from app.config import settings, should_prefer_realtime_today, today_trade_date
|
||||||
from app.data.models import (
|
from app.data.models import (
|
||||||
MarketTemperature,
|
MarketTemperature,
|
||||||
Recommendation,
|
Recommendation,
|
||||||
@ -19,6 +20,35 @@ from app.data.models import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _sector_pct(sector: SectorInfo) -> float:
|
||||||
|
return float(sector.realtime_pct_change if sector.realtime_pct_change is not None else sector.pct_change)
|
||||||
|
|
||||||
|
|
||||||
|
def _sector_turnover(sector: SectorInfo) -> float:
|
||||||
|
return float(sector.realtime_turnover_rate if sector.realtime_turnover_rate is not None else sector.turnover_avg)
|
||||||
|
|
||||||
|
|
||||||
|
def _sector_up_count(sector: SectorInfo) -> int:
|
||||||
|
return int(sector.realtime_up_count if sector.realtime_up_count is not None else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _sector_down_count(sector: SectorInfo) -> int:
|
||||||
|
return int(sector.realtime_down_count if sector.realtime_down_count is not None else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _sector_breadth(sector: SectorInfo) -> int:
|
||||||
|
return _sector_up_count(sector) - _sector_down_count(sector)
|
||||||
|
|
||||||
|
|
||||||
|
def _sector_strength_score(sector: SectorInfo) -> float:
|
||||||
|
strength = _sector_pct(sector) * 12
|
||||||
|
strength += min(max(_sector_breadth(sector), -30), 30) * 0.6
|
||||||
|
strength += min(_sector_turnover(sector), 12) * 1.5
|
||||||
|
strength += min(sector.limit_up_count, 8) * 2.5
|
||||||
|
strength += min(sector.days_continuous, 5) * 1.5
|
||||||
|
return round(strength, 1)
|
||||||
|
|
||||||
|
|
||||||
async def build_strategy_board(include_llm: bool = False) -> dict:
|
async def build_strategy_board(include_llm: bool = False) -> dict:
|
||||||
"""生成今日市场作战面板。"""
|
"""生成今日市场作战面板。"""
|
||||||
from app.engine.recommender import (
|
from app.engine.recommender import (
|
||||||
@ -31,6 +61,7 @@ async def build_strategy_board(include_llm: bool = False) -> dict:
|
|||||||
market_temp = latest.get("market_temp")
|
market_temp = latest.get("market_temp")
|
||||||
recommendations = latest.get("recommendations", [])
|
recommendations = latest.get("recommendations", [])
|
||||||
sectors = await get_latest_sectors()
|
sectors = await get_latest_sectors()
|
||||||
|
sectors = await enrich_sectors_with_realtime(sectors)
|
||||||
performance = await get_performance_stats()
|
performance = await get_performance_stats()
|
||||||
from app.llm.strategy_iteration import build_strategy_iteration_report
|
from app.llm.strategy_iteration import build_strategy_iteration_report
|
||||||
iteration_report = await build_strategy_iteration_report(limit=50, include_llm=include_llm)
|
iteration_report = await build_strategy_iteration_report(limit=50, include_llm=include_llm)
|
||||||
@ -59,11 +90,18 @@ def _build_rule_board(
|
|||||||
performance: dict,
|
performance: dict,
|
||||||
) -> StrategyBoard:
|
) -> StrategyBoard:
|
||||||
temp = market_temp.temperature if market_temp else 0
|
temp = market_temp.temperature if market_temp else 0
|
||||||
trade_date = market_temp.trade_date if market_temp else ""
|
raw_trade_date = market_temp.trade_date if market_temp else ""
|
||||||
|
prefer_realtime = should_prefer_realtime_today(raw_trade_date)
|
||||||
|
trade_date = today_trade_date() if prefer_realtime else raw_trade_date
|
||||||
|
data_mode = "realtime_today" if prefer_realtime else "daily_snapshot"
|
||||||
market_regime, risk_level, action_bias, position_suggestion = _classify_market(temp, market_temp)
|
market_regime, risk_level, action_bias, position_suggestion = _classify_market(temp, market_temp)
|
||||||
|
|
||||||
actionable = [r for r in recommendations if r.action_plan == "可操作"]
|
actionable = [r for r in recommendations if r.action_plan == "可操作"]
|
||||||
watch = [r for r in recommendations if r.action_plan == "重点关注"]
|
watch = [r for r in recommendations if r.action_plan == "重点关注"]
|
||||||
|
strong_sectors = [
|
||||||
|
s for s in sectors[:5]
|
||||||
|
if _sector_pct(s) >= 1.5 and (_sector_breadth(s) > 0 or s.limit_up_count >= 2)
|
||||||
|
]
|
||||||
avg_score = (
|
avg_score = (
|
||||||
round(sum(r.score for r in recommendations) / len(recommendations), 1)
|
round(sum(r.score for r in recommendations) / len(recommendations), 1)
|
||||||
if recommendations else 0
|
if recommendations else 0
|
||||||
@ -74,11 +112,13 @@ def _build_rule_board(
|
|||||||
watch_sectors = [_sector_focus(s) for s in sectors[:5]]
|
watch_sectors = [_sector_focus(s) for s in sectors[:5]]
|
||||||
avoid_rules = _build_avoid_rules(temp, sectors, recommendations)
|
avoid_rules = _build_avoid_rules(temp, sectors, recommendations)
|
||||||
iteration_notes = _build_iteration_notes(performance, recommendations)
|
iteration_notes = _build_iteration_notes(performance, recommendations)
|
||||||
|
mode_prefix = "今日实时视角:" if prefer_realtime else ""
|
||||||
|
|
||||||
summary = (
|
summary = (
|
||||||
f"{market_regime},风险等级{risk_level}。"
|
f"{mode_prefix}{market_regime},风险等级{risk_level}。"
|
||||||
f"当前 {len(recommendations)} 只入选,其中 {len(actionable)} 只可操作、"
|
f"当前 {len(recommendations)} 只入选,其中 {len(actionable)} 只可操作、"
|
||||||
f"{len(watch)} 只重点关注,平均分 {avg_score}。"
|
f"{len(watch)} 只重点关注,平均分 {avg_score}。"
|
||||||
|
f"{'主线活跃板块 ' + ' / '.join(s.sector_name for s in strong_sectors[:3]) + '。' if strong_sectors else '板块尚未形成强共振,优先等确认。'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
metrics = {
|
metrics = {
|
||||||
@ -94,6 +134,7 @@ def _build_rule_board(
|
|||||||
|
|
||||||
return StrategyBoard(
|
return StrategyBoard(
|
||||||
trade_date=trade_date,
|
trade_date=trade_date,
|
||||||
|
data_mode=data_mode,
|
||||||
market_regime=market_regime,
|
market_regime=market_regime,
|
||||||
risk_level=risk_level,
|
risk_level=risk_level,
|
||||||
action_bias=action_bias,
|
action_bias=action_bias,
|
||||||
@ -125,11 +166,17 @@ def _classify_market(
|
|||||||
def _choose_strategy_mode(
|
def _choose_strategy_mode(
|
||||||
temp: float, sectors: list[SectorInfo], recommendations: list[Recommendation]
|
temp: float, sectors: list[SectorInfo], recommendations: list[Recommendation]
|
||||||
) -> str:
|
) -> str:
|
||||||
early_mid = [s for s in sectors[:5] if s.stage in ("early", "mid")]
|
top = sectors[:5]
|
||||||
if temp >= 60 and early_mid:
|
strong = [s for s in top if _sector_pct(s) >= 2 and _sector_breadth(s) > 0]
|
||||||
return "主线突破 + 回踩确认"
|
active = [s for s in top if _sector_pct(s) >= 1 and (_sector_turnover(s) >= 2 or s.limit_up_count >= 2)]
|
||||||
if temp >= 45:
|
end_stage = [s for s in top if s.stage == "end" and _sector_pct(s) > 0]
|
||||||
return "精选回踩,降低追高"
|
|
||||||
|
if temp >= 65 and len(strong) >= 2:
|
||||||
|
return "顺主线做强势确认"
|
||||||
|
if temp >= 55 and active:
|
||||||
|
return "围绕强板块做回踩确认"
|
||||||
|
if temp < 45 or end_stage:
|
||||||
|
return "缩仓观察,回避后排追高"
|
||||||
if recommendations:
|
if recommendations:
|
||||||
return "观察池跟踪,等待触发"
|
return "观察池跟踪,等待触发"
|
||||||
return "防守观察"
|
return "防守观察"
|
||||||
@ -159,16 +206,37 @@ def _build_strategy_focus(
|
|||||||
|
|
||||||
if sectors:
|
if sectors:
|
||||||
main = sectors[0]
|
main = sectors[0]
|
||||||
|
sector_pct = _sector_pct(main)
|
||||||
|
breadth = _sector_breadth(main)
|
||||||
|
turnover = _sector_turnover(main)
|
||||||
focus.append(StrategyFocus(
|
focus.append(StrategyFocus(
|
||||||
label=f"{main.sector_name} 主线跟踪",
|
label=f"{main.sector_name} 主线跟踪",
|
||||||
description=f"热度 {main.heat_score},阶段 {main.stage},优先确认资金是否延续。",
|
description=(
|
||||||
|
f"当前涨幅 {sector_pct:+.2f}% ,广度 {breadth:+d},换手 {turnover:.1f}% ,"
|
||||||
|
f"阶段 {main.stage},优先确认资金是否延续。"
|
||||||
|
),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
if len(sectors) > 1:
|
||||||
|
runner_up = sectors[1]
|
||||||
|
focus.append(StrategyFocus(
|
||||||
|
label=f"{runner_up.sector_name} 轮动监控",
|
||||||
|
description=(
|
||||||
|
f"强度分 {_sector_strength_score(runner_up)},"
|
||||||
|
f"若涨幅继续扩大且广度转强,可切入今日第二梯队。"
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
if temp < 45:
|
if temp < 45:
|
||||||
focus.append(StrategyFocus(
|
focus.append(StrategyFocus(
|
||||||
label="防守优先",
|
label="防守优先",
|
||||||
description="市场温度不足,推荐只作为观察池,不宜扩大仓位。",
|
description="市场温度不足,推荐只作为观察池,不宜扩大仓位。",
|
||||||
))
|
))
|
||||||
|
elif sectors and _sector_pct(sectors[0]) < 1:
|
||||||
|
focus.append(StrategyFocus(
|
||||||
|
label="等一致性强化",
|
||||||
|
description="板块领涨幅度仍有限,优先等主线扩散和个股触发后再出手。",
|
||||||
|
))
|
||||||
|
|
||||||
return focus
|
return focus
|
||||||
|
|
||||||
@ -185,9 +253,19 @@ def _sector_focus(sector: SectorInfo) -> StrategySectorFocus:
|
|||||||
sector_name=sector.sector_name,
|
sector_name=sector.sector_name,
|
||||||
stage=sector.stage,
|
stage=sector.stage,
|
||||||
heat_score=sector.heat_score,
|
heat_score=sector.heat_score,
|
||||||
pct_change=sector.pct_change,
|
pct_change=_sector_pct(sector),
|
||||||
limit_up_count=sector.limit_up_count,
|
limit_up_count=sector.limit_up_count,
|
||||||
view=stage_view,
|
turnover_rate=_sector_turnover(sector),
|
||||||
|
up_count=_sector_up_count(sector),
|
||||||
|
down_count=_sector_down_count(sector),
|
||||||
|
data_mode=sector.data_mode,
|
||||||
|
view=(
|
||||||
|
f"{stage_view};当前涨幅 {_sector_pct(sector):+.2f}%"
|
||||||
|
+ (
|
||||||
|
f",上涨/下跌 {_sector_up_count(sector)}/{_sector_down_count(sector)}"
|
||||||
|
if sector.is_realtime else ""
|
||||||
|
)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -195,10 +273,17 @@ def _build_avoid_rules(
|
|||||||
temp: float, sectors: list[SectorInfo], recommendations: list[Recommendation]
|
temp: float, sectors: list[SectorInfo], recommendations: list[Recommendation]
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
rules = []
|
rules = []
|
||||||
|
top = sectors[:5]
|
||||||
if temp < 45:
|
if temp < 45:
|
||||||
rules.append("市场温度低于45时,不追突破首日,只等次日确认或回踩。")
|
rules.append("市场温度低于45时,不追突破首日,只等次日确认或回踩。")
|
||||||
if any(s.stage == "end" for s in sectors[:5]):
|
if any(s.stage == "end" for s in top):
|
||||||
rules.append("板块进入末期时,降低同板块追高标的权重。")
|
rules.append("板块进入末期时,降低同板块追高标的权重。")
|
||||||
|
if top and _sector_pct(top[0]) < 1:
|
||||||
|
rules.append("主线板块涨幅不足1%时,不把局部异动当成全面进攻信号。")
|
||||||
|
if any(_sector_breadth(s) < 0 for s in top[:3] if s.is_realtime):
|
||||||
|
rules.append("板块涨幅与广度背离时,优先回避后排补涨和冲高追单。")
|
||||||
|
if any(_sector_turnover(s) > 8 and s.stage in ("late", "end") for s in top):
|
||||||
|
rules.append("高换手叠加板块后期阶段时,防止情绪过热后的快速分歧。")
|
||||||
if any(r.position_score < 35 for r in recommendations):
|
if any(r.position_score < 35 for r in recommendations):
|
||||||
rules.append("位置安全分低于35的标的,只观察不主动追入。")
|
rules.append("位置安全分低于35的标的,只观察不主动追入。")
|
||||||
if not rules:
|
if not rules:
|
||||||
|
|||||||
Binary file not shown.
@ -70,11 +70,6 @@
|
|||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
"static/chunks/app/(public)/page.js"
|
"static/chunks/app/(public)/page.js"
|
||||||
],
|
|
||||||
"/_not-found/page": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main-app.js",
|
|
||||||
"static/chunks/app/_not-found/page.js"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"/_not-found/page": "app/_not-found/page.js",
|
|
||||||
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
|
|
||||||
"/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js",
|
"/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js",
|
||||||
|
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
|
||||||
"/(auth)/chat/page": "app/(auth)/chat/page.js",
|
"/(auth)/chat/page": "app/(auth)/chat/page.js",
|
||||||
"/(public)/login/page": "app/(public)/login/page.js",
|
"/(public)/login/page": "app/(public)/login/page.js",
|
||||||
"/(public)/page": "app/(public)/page.js"
|
"/(public)/page": "app/(public)/page.js"
|
||||||
|
|||||||
@ -350,18 +350,34 @@ function MissionControl({
|
|||||||
const risks = board?.avoid_rules?.length ? board.avoid_rules : ["等待市场状态和推荐结果更新后生成风险约束。"];
|
const risks = board?.avoid_rules?.length ? board.avoid_rules : ["等待市场状态和推荐结果更新后生成风险约束。"];
|
||||||
const primaryQueue = (actionable.length ? actionable : watch).slice(0, 3);
|
const primaryQueue = (actionable.length ? actionable : watch).slice(0, 3);
|
||||||
const laneTitle = actionable.length ? "优先执行" : watch.length ? "候选观察" : "等待信号";
|
const laneTitle = actionable.length ? "优先执行" : watch.length ? "候选观察" : "等待信号";
|
||||||
const strategyName = strategyProfile?.name ?? board?.recommended_mode ?? "待定";
|
const strategyName = strategyProfile?.name && strategyProfile.name !== "当前推荐策略"
|
||||||
const strategyHint = strategyProfile?.description ?? "系统判断今天更适合采用的出手方式";
|
? strategyProfile.name
|
||||||
const riskText = risks.slice(0, 2).join(" / ");
|
: 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,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card-static px-4 py-4 md:px-5 md:py-5 overflow-hidden relative animate-fade-in-up">
|
<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="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)_300px] gap-4">
|
<div className="relative grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_280px] gap-3">
|
||||||
<div className="min-w-0 space-y-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 justify-between gap-2">
|
||||||
<div className="flex flex-wrap items-center 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>
|
<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" && (
|
{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>
|
<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>
|
||||||
)}
|
)}
|
||||||
@ -371,14 +387,19 @@ function MissionControl({
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_220px] gap-3 items-start">
|
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_200px] gap-2.5 items-start">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-lg md:text-[1.2rem] font-bold tracking-tight truncate">
|
<h2 className="text-lg md:text-[1.2rem] font-bold tracking-tight truncate">
|
||||||
{board?.market_regime ?? "等待市场状态"}
|
{board?.market_regime ?? "等待市场状态"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[12px] text-text-secondary leading-relaxed mt-1.5 max-w-3xl line-clamp-2">
|
<p className="text-[12px] text-text-secondary leading-relaxed mt-1 max-w-3xl line-clamp-2">
|
||||||
{board?.summary ?? "系统尚未生成今日作战结论。触发扫描后,将基于市场温度、板块主线、推荐生命周期和策略复盘生成操作框架。"}
|
{board?.summary ?? "系统尚未生成今日作战结论。触发扫描后,将基于市场温度、板块主线、推荐生命周期和策略复盘生成操作框架。"}
|
||||||
</p>
|
</p>
|
||||||
|
{board?.trade_date && (
|
||||||
|
<div className="text-[10px] text-text-muted mt-1">
|
||||||
|
{isRealtimeBoard ? `分析日期 ${board.trade_date} · 今日实时优先` : `数据日期 ${board.trade_date}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 lg:grid-cols-1 gap-1.5">
|
<div className="grid grid-cols-3 lg:grid-cols-1 gap-1.5">
|
||||||
<CommandMetric label="可操作" value={actionable.length} description="盯盘" />
|
<CommandMetric label="可操作" value={actionable.length} description="盯盘" />
|
||||||
@ -387,23 +408,32 @@ function MissionControl({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-2">
|
<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={strategyName} extra={strategyHint} />
|
||||||
<CompactDecision label="仓位建议" value={board?.position_suggestion ?? "等待判断"} extra="今天建议的进攻上限" />
|
<CompactDecision label="仓位建议" value={board?.position_suggestion ?? "等待判断"} extra="今天建议的进攻上限" />
|
||||||
<CompactDecision label="市场倾向" value={board?.action_bias ?? "等待确认"} extra={`风险 ${board?.risk_level ?? "-"}`} />
|
<CompactDecision label="市场倾向" value={board?.action_bias ?? "等待确认"} extra={`风险 ${board?.risk_level ?? "-"}`} />
|
||||||
<CompactDecision label="主线板块" value={topSectors.length ? topSectors.map((sector) => sector.sector_name).join(" / ") : "暂无"} extra={riskText || "暂无风险约束"} />
|
<CompactDecision label="风险约束" value={risks[0] ?? "暂无"} extra={risks[1] ?? "等待更多市场证据"} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="rounded-xl bg-surface-1/70 border border-border-subtle p-2.5">
|
||||||
{topSectors.length ? (
|
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold mb-2">主线证据</div>
|
||||||
topSectors.map((sector) => (
|
{topSectorEvidence.length ? (
|
||||||
<span key={sector.sector_code} className="inline-flex items-center gap-1.5 rounded-full bg-surface-1/80 border border-border-subtle px-2.5 py-1 text-[11px]">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||||
<span className="font-medium text-text-primary">{sector.sector_name}</span>
|
{topSectorEvidence.map((sector) => (
|
||||||
<span className={`font-mono tabular-nums ${sector.pct_change >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
<div key={sector.key} className="rounded-lg bg-surface-2/70 border border-border-subtle px-2.5 py-2">
|
||||||
{sector.pct_change > 0 ? "+" : ""}{sector.pct_change.toFixed(2)}%
|
<div className="flex items-center justify-between gap-2">
|
||||||
</span>
|
<span className="text-[12px] font-semibold text-text-primary truncate">{sector.name}</span>
|
||||||
</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>
|
<span className="text-xs text-text-muted">暂无主线板块</span>
|
||||||
)}
|
)}
|
||||||
@ -443,8 +473,8 @@ function CompactDecision({ label, value, extra }: { label: string; value: string
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-surface-2 px-3 py-2">
|
<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-[10px] text-text-muted uppercase tracking-wider font-semibold">{label}</div>
|
||||||
<div className="text-sm font-semibold text-text-primary mt-1 truncate">{value}</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-1 truncate">{extra}</div> : null}
|
{extra ? <div className="text-[10px] text-text-muted mt-0.5 line-clamp-2">{extra}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -455,6 +485,7 @@ function CompactMissionStock({ rec }: { rec: LatestResult["recommendations"][num
|
|||||||
"重点关注": "bg-amber-500/15 text-amber-400 border-amber-500/20",
|
"重点关注": "bg-amber-500/15 text-amber-400 border-amber-500/20",
|
||||||
"观察": "bg-surface-3 text-text-muted border-border-default",
|
"观察": "bg-surface-3 text-text-muted border-border-default",
|
||||||
};
|
};
|
||||||
|
const aiConviction = rec.llm_score != null ? Math.round(rec.llm_score) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@ -474,12 +505,21 @@ function CompactMissionStock({ rec }: { rec: LatestResult["recommendations"][num
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right">
|
<div className="shrink-0 text-right">
|
||||||
<div className="text-base font-bold font-mono tabular-nums text-text-primary">{rec.score}</div>
|
{aiConviction != null ? (
|
||||||
<div className="text-[10px] text-text-muted">参考</div>
|
<>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 text-[11px] text-text-secondary leading-relaxed line-clamp-1">
|
<div className="mt-1.5 text-[11px] text-text-secondary leading-relaxed line-clamp-1">
|
||||||
{rec.trigger_condition ?? rec.reasons?.[0] ?? "等待触发条件确认"}
|
{rec.trigger_condition ?? rec.entry_timing ?? rec.reasons?.[0] ?? "等待触发条件确认"}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -301,7 +301,7 @@ export default function DiagnosePage() {
|
|||||||
<div className="glass-card-static p-10 text-center animate-fade-in-up">
|
<div className="glass-card-static p-10 text-center animate-fade-in-up">
|
||||||
<div className="w-8 h-8 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-3" />
|
<div className="w-8 h-8 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-3" />
|
||||||
<div className="text-sm text-text-secondary mb-1">正在分析中...</div>
|
<div className="text-sm text-text-secondary mb-1">正在分析中...</div>
|
||||||
<div className="text-xs text-text-muted/50">收集行情数据、技术指标、资金流向并生成分析报告</div>
|
<div className="text-xs text-text-muted/50">收集行情、板块和推荐归档,生成本次会诊结论</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -345,6 +345,15 @@ export default function DiagnosePage() {
|
|||||||
<DiagnosisSummaryCard label="风险线索" value={buildDiagnosisRisk(thesis, loading)} />
|
<DiagnosisSummaryCard label="风险线索" value={buildDiagnosisRisk(thesis, loading)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold mb-3">会诊摘要</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
<DiagnosisSummaryCard label="执行建议" value={buildDiagnosisAction(thesis, diagnoseMode)} />
|
||||||
|
<DiagnosisSummaryCard label="下一步" value={buildDiagnosisNextStep(thesis, diagnoseMode)} />
|
||||||
|
<DiagnosisSummaryCard label="触发关注" value={thesis?.recommendation?.trigger_condition || "等待 AI 在长文中补充"} />
|
||||||
|
<DiagnosisSummaryCard label="失效边界" value={thesis?.recommendation?.invalidation_condition || "等待 AI 在长文中补充"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="glass-card-static p-5">
|
<div className="glass-card-static p-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -506,3 +515,38 @@ function buildDiagnosisRisk(thesis: StockThesisResponse | null, loading: boolean
|
|||||||
}
|
}
|
||||||
return "当前没有明确风险备注";
|
return "当前没有明确风险备注";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDiagnosisAction(
|
||||||
|
thesis: StockThesisResponse | null,
|
||||||
|
mode: "entry" | "holding" | "review" | "tracking",
|
||||||
|
) {
|
||||||
|
if (thesis?.recommendation?.action_plan === "可操作") {
|
||||||
|
return "仅在触发条件成立时执行,不提前交易。";
|
||||||
|
}
|
||||||
|
if (thesis?.recommendation?.action_plan === "重点关注") {
|
||||||
|
return "继续跟踪板块和个股强度,等确认后再做动作。";
|
||||||
|
}
|
||||||
|
if (mode === "holding") {
|
||||||
|
return "优先核对持仓逻辑、失效条件和风险暴露。";
|
||||||
|
}
|
||||||
|
if (mode === "review") {
|
||||||
|
return "先定位问题出在市场环境、板块节奏还是个股执行。";
|
||||||
|
}
|
||||||
|
return "当前以观察和补充证据为主。";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDiagnosisNextStep(
|
||||||
|
thesis: StockThesisResponse | null,
|
||||||
|
mode: "entry" | "holding" | "review" | "tracking",
|
||||||
|
) {
|
||||||
|
if (thesis?.recommendation?.trigger_condition) {
|
||||||
|
return `重点盯住:${thesis.recommendation.trigger_condition}`;
|
||||||
|
}
|
||||||
|
if (mode === "tracking") {
|
||||||
|
return "继续观察是否进入可操作或重点关注状态。";
|
||||||
|
}
|
||||||
|
if (mode === "holding") {
|
||||||
|
return "检查是否需要减仓、止损或继续持有。";
|
||||||
|
}
|
||||||
|
return "结合下一个交易时段的量价和板块变化再判断。";
|
||||||
|
}
|
||||||
|
|||||||
@ -158,6 +158,27 @@ export default function RecommendationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-card-static p-4 mb-5 animate-fade-in-up">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold mb-2">Recommendation Logic</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||||
|
<GlossaryCard
|
||||||
|
label="AI 裁决"
|
||||||
|
value="最终结论"
|
||||||
|
description="决定标的属于可操作、重点关注还是观察,并给出动作优先级。"
|
||||||
|
/>
|
||||||
|
<GlossaryCard
|
||||||
|
label="触发 / 失效"
|
||||||
|
value="执行边界"
|
||||||
|
description="真正进入交易动作前先看触发条件,失效条件负责退出和否决。"
|
||||||
|
/>
|
||||||
|
<GlossaryCard
|
||||||
|
label="规则参考"
|
||||||
|
value="辅助证据"
|
||||||
|
description="供需、形态、趋势、位置等规则分只做证据,不单独决定最终推荐。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{opsStatus && (
|
{opsStatus && (
|
||||||
<div className="glass-card-static p-4 mb-5 animate-fade-in-up">
|
<div className="glass-card-static p-4 mb-5 animate-fade-in-up">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
@ -333,8 +354,6 @@ export default function RecommendationsPage() {
|
|||||||
{/* Mobile stats inline after date */}
|
{/* Mobile stats inline after date */}
|
||||||
<div className="flex items-center gap-2 text-[10px] text-text-muted sm:hidden ml-auto shrink-0">
|
<div className="flex items-center gap-2 text-[10px] text-text-muted sm:hidden ml-auto shrink-0">
|
||||||
<span className="font-mono tabular-nums">{filter === "all" ? group.count : filtered.length}只</span>
|
<span className="font-mono tabular-nums">{filter === "all" ? group.count : filtered.length}只</span>
|
||||||
<span className="text-text-muted/40">·</span>
|
|
||||||
<span className="font-mono tabular-nums text-text-secondary">参考{group.avg_score}</span>
|
|
||||||
{group.buy_count > 0 && (
|
{group.buy_count > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-text-muted/40">·</span>
|
<span className="text-text-muted/40">·</span>
|
||||||
@ -346,12 +365,6 @@ export default function RecommendationsPage() {
|
|||||||
|
|
||||||
{/* Desktop stats */}
|
{/* Desktop stats */}
|
||||||
<div className="hidden sm:flex items-center gap-4 text-xs text-text-muted shrink-0">
|
<div className="hidden sm:flex items-center gap-4 text-xs text-text-muted shrink-0">
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-text-muted/50">参考均值</span>
|
|
||||||
<span className="font-mono tabular-nums font-semibold text-text-secondary">
|
|
||||||
{group.avg_score}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-text-muted/50">买入</span>
|
<span className="text-text-muted/50">买入</span>
|
||||||
<span className="font-mono tabular-nums font-semibold text-red-400">
|
<span className="font-mono tabular-nums font-semibold text-red-400">
|
||||||
@ -505,9 +518,9 @@ function RecommendationCommandCenter({
|
|||||||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold mb-2">
|
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold mb-2">
|
||||||
Recommendation Ops
|
Recommendation Ops
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl md:text-2xl font-bold tracking-tight">推荐不是排行榜,是执行管线</h2>
|
<h2 className="text-xl md:text-2xl font-bold tracking-tight">推荐闭环</h2>
|
||||||
<p className="text-sm text-text-secondary leading-relaxed mt-2">
|
<p className="text-sm text-text-secondary leading-relaxed mt-2">
|
||||||
这里把 AI 选股结果拆成四个动作池:能操作的先盯触发,未确认的继续等待,已推荐的持续跟踪,结束后的样本进入策略迭代。
|
先看可操作和重点关注,再看跟踪结果,结束样本自动回流到策略迭代。
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-4 gap-2 mt-4">
|
<div className="grid grid-cols-4 gap-2 mt-4">
|
||||||
<PipelineMetric label="今日" value={latestCount} />
|
<PipelineMetric label="今日" value={latestCount} />
|
||||||
@ -539,6 +552,28 @@ function RecommendationCommandCenter({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GlossaryCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-surface-1 border border-border-subtle px-3 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">{label}</div>
|
||||||
|
<div className="text-[10px] px-2 py-0.5 rounded-full border border-border-subtle bg-surface-2 text-text-secondary">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-secondary leading-relaxed mt-2">{description}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PipelineMetric({ label, value, tone }: { label: string; value: number; tone?: "red" | "amber" | "cyan" }) {
|
function PipelineMetric({ label, value, tone }: { label: string; value: number; tone?: "red" | "amber" | "cyan" }) {
|
||||||
const color = tone === "red" ? "text-red-400" : tone === "amber" ? "text-amber-400" : tone === "cyan" ? "text-cyan-400" : "text-text-primary";
|
const color = tone === "red" ? "text-red-400" : tone === "amber" ? "text-amber-400" : tone === "cyan" ? "text-cyan-400" : "text-text-primary";
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -110,6 +110,10 @@ function SectorDetailCard({ sector, index, factorScores }: {
|
|||||||
const displayPct = sector.realtime_pct_change ?? sector.pct_change;
|
const displayPct = sector.realtime_pct_change ?? sector.pct_change;
|
||||||
const isUp = displayPct > 0;
|
const isUp = displayPct > 0;
|
||||||
const displayLimitUp = sector.realtime_limit_up_count ?? sector.limit_up_count;
|
const displayLimitUp = sector.realtime_limit_up_count ?? sector.limit_up_count;
|
||||||
|
const displayAmount = sector.realtime_amount ?? sector.capital_inflow;
|
||||||
|
const displayTurnover = sector.realtime_turnover_rate ?? sector.turnover_avg ?? 0;
|
||||||
|
const displayUpCount = sector.realtime_up_count;
|
||||||
|
const displayDownCount = sector.realtime_down_count;
|
||||||
const leaders = sector.is_realtime
|
const leaders = sector.is_realtime
|
||||||
? (sector.leading_stocks_realtime?.length ? sector.leading_stocks_realtime : sector.leading_stocks)
|
? (sector.leading_stocks_realtime?.length ? sector.leading_stocks_realtime : sector.leading_stocks)
|
||||||
: sector.leading_stocks;
|
: sector.leading_stocks;
|
||||||
@ -186,24 +190,36 @@ function SectorDetailCard({ sector, index, factorScores }: {
|
|||||||
{/* Metrics row - 4 columns */}
|
{/* Metrics row - 4 columns */}
|
||||||
<div className="grid grid-cols-4 gap-2 mb-3">
|
<div className="grid grid-cols-4 gap-2 mb-3">
|
||||||
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
|
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
|
||||||
<div className="text-[10px] text-text-muted/50 mb-0.5">资金净流入</div>
|
<div className="text-[10px] text-text-muted/50 mb-0.5">{sector.is_realtime ? "实时成交额" : "资金净流入"}</div>
|
||||||
<div className={`text-xs font-mono tabular-nums font-semibold ${sector.capital_inflow > 0 ? "text-red-400" : "text-emerald-400"}`}>
|
<div className={`text-xs font-mono tabular-nums font-semibold ${displayAmount > 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||||
{sector.capital_inflow > 0 ? "+" : ""}{formatNumber(sector.capital_inflow)}
|
{displayAmount > 0 ? "+" : ""}{formatNumber(displayAmount)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
|
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
|
||||||
<div className="text-[10px] text-text-muted/50 mb-0.5">涨停股</div>
|
<div className="text-[10px] text-text-muted/50 mb-0.5">{sector.is_realtime ? "上涨/下跌" : "涨停股"}</div>
|
||||||
<div className="text-xs font-mono tabular-nums font-semibold text-text-secondary">
|
{sector.is_realtime && displayUpCount != null && displayDownCount != null ? (
|
||||||
{displayLimitUp}<span className="text-text-muted/40 text-[10px]"> 只</span>
|
<div className="text-xs font-mono tabular-nums font-semibold text-text-secondary">
|
||||||
</div>
|
{displayUpCount}<span className="text-text-muted/40 text-[10px]"> / </span>{displayDownCount}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs font-mono tabular-nums font-semibold text-text-secondary">
|
||||||
|
{displayLimitUp}<span className="text-text-muted/40 text-[10px]"> 只</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
|
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
|
||||||
<div className="text-[10px] text-text-muted/50 mb-0.5">主力占比</div>
|
<div className="text-[10px] text-text-muted/50 mb-0.5">{sector.is_realtime ? "实时换手" : "主力占比"}</div>
|
||||||
<div className={`text-xs font-mono tabular-nums font-semibold ${
|
{sector.is_realtime ? (
|
||||||
mainForceRatio > 30 ? "text-amber-400" : mainForceRatio < 0 ? "text-red-400" : "text-text-secondary"
|
<div className="text-xs font-mono tabular-nums font-semibold text-text-secondary">
|
||||||
}`}>
|
{displayTurnover.toFixed(1)}<span className="text-text-muted/40 text-[10px]">%</span>
|
||||||
{mainForceRatio.toFixed(1)}<span className="text-text-muted/40 text-[10px]">%</span>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className={`text-xs font-mono tabular-nums font-semibold ${
|
||||||
|
mainForceRatio > 30 ? "text-amber-400" : mainForceRatio < 0 ? "text-red-400" : "text-text-secondary"
|
||||||
|
}`}>
|
||||||
|
{mainForceRatio.toFixed(1)}<span className="text-text-muted/40 text-[10px]">%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
|
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
|
||||||
<div className="text-[10px] text-text-muted/50 mb-0.5">热度评分</div>
|
<div className="text-[10px] text-text-muted/50 mb-0.5">热度评分</div>
|
||||||
@ -344,6 +360,8 @@ export default function SectorsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasRealtime = sectors.some((s) => s.is_realtime);
|
const hasRealtime = sectors.some((s) => s.is_realtime);
|
||||||
|
const structureTradeDate = sectors[0]?.structure_trade_date || sectors[0]?.trade_date || "";
|
||||||
|
const dataMode = sectors[0]?.data_mode || "daily_snapshot";
|
||||||
|
|
||||||
const loadRotation = useCallback(async () => {
|
const loadRotation = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -405,8 +423,15 @@ export default function SectorsPage() {
|
|||||||
<h1 className="text-lg font-bold tracking-tight">板块主线</h1>
|
<h1 className="text-lg font-bold tracking-tight">板块主线</h1>
|
||||||
<p className="text-xs text-text-muted mt-0.5">
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
判断当前主线、板块阶段、资金持续性和领涨股强度
|
判断当前主线、板块阶段、资金持续性和领涨股强度
|
||||||
{hasRealtime && <span className="text-emerald-400/60 ml-1">· 实时数据</span>}
|
{hasRealtime && <span className="text-emerald-400/60 ml-1">· 盘中实时优先</span>}
|
||||||
</p>
|
</p>
|
||||||
|
{hasRealtime && dataMode === "realtime_overlay" && (
|
||||||
|
<p className="text-[11px] text-text-muted/70 mt-2">
|
||||||
|
盘中模式下,涨幅、成交额、上涨/下跌家数与领涨股为实时覆盖;阶段、资金连续性等结构字段仍基于
|
||||||
|
<span className="text-text-secondary"> {structureTradeDate || "最近交易日"} </span>
|
||||||
|
的板块快照。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!sectors.length ? (
|
{!sectors.length ? (
|
||||||
|
|||||||
@ -162,6 +162,7 @@ export default function StockDetailPage() {
|
|||||||
const latestTracking = thesis?.latest_tracking;
|
const latestTracking = thesis?.latest_tracking;
|
||||||
const latestFlow = capitalFlow.length > 0 ? capitalFlow[capitalFlow.length - 1] : null;
|
const latestFlow = capitalFlow.length > 0 ? capitalFlow[capitalFlow.length - 1] : null;
|
||||||
const pageName = recommendation?.name || thesis?.name || quote?.name || code;
|
const pageName = recommendation?.name || thesis?.name || quote?.name || code;
|
||||||
|
const aiConviction = recommendation?.llm_score != null ? Math.round(recommendation.llm_score) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
@ -181,7 +182,7 @@ export default function StockDetailPage() {
|
|||||||
<div className="relative grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-5">
|
<div className="relative grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||||
<span className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold">Stock Thesis</span>
|
<span className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold">个股作战卡</span>
|
||||||
{recommendation?.action_plan ? (
|
{recommendation?.action_plan ? (
|
||||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${getActionPlanClass(recommendation.action_plan)}`}>
|
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${getActionPlanClass(recommendation.action_plan)}`}>
|
||||||
{recommendation.action_plan}
|
{recommendation.action_plan}
|
||||||
@ -215,6 +216,12 @@ export default function StockDetailPage() {
|
|||||||
<div className="text-xs text-text-secondary leading-relaxed mt-2">
|
<div className="text-xs text-text-secondary leading-relaxed mt-2">
|
||||||
{thesis?.data_freshness.message ?? "加载中"}
|
{thesis?.data_freshness.message ?? "加载中"}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-3">
|
||||||
|
<MiniDataCell label="当前动作" value={recommendation?.action_plan || "观察"} />
|
||||||
|
<MiniDataCell label="AI 置信" value={aiConviction != null ? `${aiConviction}/10` : "暂无"} />
|
||||||
|
<MiniDataCell label="规则参考" value={recommendation ? `${Math.round(recommendation.score)}` : "暂无"} />
|
||||||
|
<MiniDataCell label="建议仓位" value={recommendation?.suggested_position_pct != null ? `${recommendation.suggested_position_pct}%` : "未设置"} />
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3 mt-3 text-[11px] text-text-muted">
|
<div className="flex flex-wrap items-center gap-3 mt-3 text-[11px] text-text-muted">
|
||||||
<span>推荐 {formatDateTime(thesis?.data_freshness.recommendation_created_at)}</span>
|
<span>推荐 {formatDateTime(thesis?.data_freshness.recommendation_created_at)}</span>
|
||||||
<span>跟踪 {thesis?.data_freshness.tracking_date || "暂无"}</span>
|
<span>跟踪 {thesis?.data_freshness.tracking_date || "暂无"}</span>
|
||||||
@ -237,7 +244,7 @@ export default function StockDetailPage() {
|
|||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4 animate-fade-in-up">
|
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4 animate-fade-in-up">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PlanCard recommendation={recommendation} trackingNote={latestTracking?.review_note || ""} />
|
<PlanCard recommendation={recommendation} trackingNote={latestTracking?.review_note || ""} />
|
||||||
<EvidenceCard recommendation={recommendation} />
|
<EvidenceCard recommendation={recommendation} quote={quote} signals={signals} />
|
||||||
<DiagnosisArchiveCard diagnoses={thesis?.diagnoses ?? []} />
|
<DiagnosisArchiveCard diagnoses={thesis?.diagnoses ?? []} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -289,28 +296,54 @@ function PlanCard({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="glass-card-static p-5">
|
<div className="glass-card-static p-5">
|
||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
<SectionTitle title="操作计划" />
|
<SectionTitle title="执行计划" />
|
||||||
{recommendation?.llm_score != null ? (
|
{recommendation?.llm_score != null ? (
|
||||||
<span className="text-xs font-mono tabular-nums text-cyan-400/80">AI {recommendation.llm_score}/10</span>
|
<span className="text-xs font-mono tabular-nums text-cyan-400/80">AI 置信 {Math.round(recommendation.llm_score)}/10</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<PlanRow label="触发条件" value={recommendation?.trigger_condition || "暂无明确触发条件"} />
|
<PlanRow label="触发条件" value={recommendation?.trigger_condition || "暂无明确触发条件"} />
|
||||||
<PlanRow label="失效条件" value={recommendation?.invalidation_condition || "暂无明确失效条件"} />
|
<PlanRow label="失效条件" value={recommendation?.invalidation_condition || "暂无明确失效条件"} />
|
||||||
<PlanRow label="建议仓位" value={recommendation?.suggested_position_pct != null ? `${recommendation.suggested_position_pct}%` : "未设置"} />
|
<PlanRow label="建议仓位" value={recommendation?.suggested_position_pct != null ? `${recommendation.suggested_position_pct}%` : "未设置"} />
|
||||||
<PlanRow label="复盘周期" value={recommendation?.review_after_days ? `${recommendation.review_after_days}个交易日` : "未设置"} />
|
<PlanRow label="复盘周期" value={recommendation?.review_after_days ? `${recommendation.review_after_days}个交易日` : "未设置"} />
|
||||||
|
{recommendation ? <PlanRow label="规则参考" value={`${Math.round(recommendation.score)} 分(辅助证据)`} /> : null}
|
||||||
{trackingNote ? <PlanRow label="跟踪结论" value={trackingNote} /> : null}
|
{trackingNote ? <PlanRow label="跟踪结论" value={trackingNote} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EvidenceCard({ recommendation }: { recommendation: RecommendationData | null | undefined }) {
|
function EvidenceCard({
|
||||||
|
recommendation,
|
||||||
|
quote,
|
||||||
|
signals,
|
||||||
|
}: {
|
||||||
|
recommendation: RecommendationData | null | undefined;
|
||||||
|
quote: QuoteData | null;
|
||||||
|
signals: StockSignals | null;
|
||||||
|
}) {
|
||||||
const reasons = recommendation?.reasons ?? [];
|
const reasons = recommendation?.reasons ?? [];
|
||||||
|
const evidenceChips = [
|
||||||
|
recommendation?.entry_signal_type ? `信号 ${signalTypeLabel(recommendation.entry_signal_type)}` : null,
|
||||||
|
quote?.pct_chg != null ? `涨幅 ${quote.pct_chg > 0 ? "+" : ""}${quote.pct_chg.toFixed(2)}%` : null,
|
||||||
|
quote?.turnover_rate != null ? `换手 ${quote.turnover_rate.toFixed(2)}%` : null,
|
||||||
|
signals?.ma_bullish ? "均线多头" : null,
|
||||||
|
signals?.volume_breakout ? "放量突破" : null,
|
||||||
|
signals?.pullback_support ? "回踩支撑" : null,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
return (
|
return (
|
||||||
<div className="glass-card-static p-5">
|
<div className="glass-card-static p-5">
|
||||||
<SectionTitle title="推荐依据" />
|
<SectionTitle title="作战依据" />
|
||||||
|
{evidenceChips.length ? (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
{evidenceChips.slice(0, 6).map((item) => (
|
||||||
|
<span key={item} className="text-[11px] px-2.5 py-1 rounded-lg bg-surface-2 border border-border-subtle text-text-secondary">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="space-y-2 mt-3">
|
<div className="space-y-2 mt-3">
|
||||||
{reasons.length ? reasons.map((reason, index) => (
|
{reasons.length ? reasons.map((reason, index) => (
|
||||||
<div key={index} className="text-sm text-text-secondary leading-relaxed flex items-start gap-2">
|
<div key={index} className="text-sm text-text-secondary leading-relaxed flex items-start gap-2">
|
||||||
@ -381,7 +414,7 @@ function TrackingCard({ tracking }: { tracking: StockThesisResponse["latest_trac
|
|||||||
function QuoteSnapshot({ quote, evidenceLoaded }: { quote: QuoteData | null; evidenceLoaded: boolean }) {
|
function QuoteSnapshot({ quote, evidenceLoaded }: { quote: QuoteData | null; evidenceLoaded: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="glass-card-static p-5">
|
<div className="glass-card-static p-5">
|
||||||
<SectionTitle title="行情快照" />
|
<SectionTitle title="盘面快照" />
|
||||||
{quote ? (
|
{quote ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-baseline gap-3 mt-3">
|
<div className="flex items-baseline gap-3 mt-3">
|
||||||
@ -419,14 +452,14 @@ function SignalSnapshot({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="glass-card-static p-5">
|
<div className="glass-card-static p-5">
|
||||||
<SectionTitle title="证据维度" />
|
<SectionTitle title="信号结论" />
|
||||||
{signals ? (
|
{signals ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-3 mt-3">
|
<div className="grid grid-cols-2 gap-2 mt-3">
|
||||||
<DimensionScore label="供需" value={recScore?.supply_demand_score ?? signals.trend_score} />
|
<MiniDataCell label="技术倾向" value={signalBias(signals, recScore)} />
|
||||||
<DimensionScore label="形态" value={recScore?.price_action_score ?? signals.signal_count * 12} />
|
<MiniDataCell label="位置状态" value={positionComment(recScore?.position_score ?? signals.position_score)} />
|
||||||
<DimensionScore label="趋势" value={recScore?.technical_score ?? signals.trend_score} />
|
<MiniDataCell label="支撑参考" value={signals.support_price != null ? signals.support_price.toFixed(2) : "暂无"} />
|
||||||
<DimensionScore label="位置" value={recScore?.position_score ?? signals.position_score} />
|
<MiniDataCell label="风控参考" value={signals.stop_loss_price != null ? signals.stop_loss_price.toFixed(2) : "暂无"} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-2 mt-3">
|
<div className="grid grid-cols-3 gap-2 mt-3">
|
||||||
<SignalFlag label="均线多头" active={signals.ma_bullish} />
|
<SignalFlag label="均线多头" active={signals.ma_bullish} />
|
||||||
@ -515,22 +548,6 @@ function TrackingMetric({ label, value }: { label: string; value: number | null
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DimensionScore({ label, value }: { label: string; value: number }) {
|
|
||||||
const width = Math.max(0, Math.min(value, 100));
|
|
||||||
const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low";
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className="text-xs text-text-secondary">{label}</span>
|
|
||||||
<span className="text-xs font-mono tabular-nums text-text-muted">{value.toFixed(0)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 bg-surface-3 rounded-full overflow-hidden">
|
|
||||||
<div className={`h-full rounded-full ${gradientClass}`} style={{ width: `${width}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SignalFlag({ label, active }: { label: string; active: boolean }) {
|
function SignalFlag({ label, active }: { label: string; active: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-xl border px-3 py-2 text-xs ${active ? "border-red-500/15 bg-red-500/[0.05] text-red-400" : "border-border-subtle bg-surface-2 text-text-muted"}`}>
|
<div className={`rounded-xl border px-3 py-2 text-xs ${active ? "border-red-500/15 bg-red-500/[0.05] text-red-400" : "border-border-subtle bg-surface-2 text-text-muted"}`}>
|
||||||
@ -606,3 +623,28 @@ function formatFlowAmount(val: number): string {
|
|||||||
if (absVal >= 10000) return (val / 10000).toFixed(1) + "亿";
|
if (absVal >= 10000) return (val / 10000).toFixed(1) + "亿";
|
||||||
return val.toFixed(0) + "万";
|
return val.toFixed(0) + "万";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function signalTypeLabel(signalType?: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
breakout: "突破",
|
||||||
|
pullback: "回踩",
|
||||||
|
launch: "启动",
|
||||||
|
reversal: "反转",
|
||||||
|
breakout_confirm: "突破确认",
|
||||||
|
};
|
||||||
|
return map[signalType || ""] ?? "观察";
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalBias(signals: StockSignals, recScore: RecScore | null) {
|
||||||
|
const trend = recScore?.technical_score ?? signals.trend_score;
|
||||||
|
if (trend >= 75 && signals.volume_breakout) return "偏强,可等确认";
|
||||||
|
if (trend >= 60) return "中性偏强";
|
||||||
|
if (signals.pullback_support) return "等待支撑确认";
|
||||||
|
return "仍需观察";
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionComment(positionScore: number) {
|
||||||
|
if (positionScore >= 75) return "位置相对安全";
|
||||||
|
if (positionScore >= 50) return "位置中性";
|
||||||
|
return "位置偏高,防追高";
|
||||||
|
}
|
||||||
|
|||||||
@ -38,6 +38,10 @@ export default function SectorHeatmap({ sectors }: { sectors: SectorData[] }) {
|
|||||||
const isTop3 = index < 3;
|
const isTop3 = index < 3;
|
||||||
// 盘中使用实时涨停数
|
// 盘中使用实时涨停数
|
||||||
const displayLimitUp = s.realtime_limit_up_count ?? s.limit_up_count;
|
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
|
// Bar width based on score relative to max
|
||||||
const barWidth = `${Math.max(intensity * 100, 15)}%`;
|
const barWidth = `${Math.max(intensity * 100, 15)}%`;
|
||||||
@ -85,14 +89,19 @@ export default function SectorHeatmap({ sectors }: { sectors: SectorData[] }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<span className={`font-mono tabular-nums ${s.capital_inflow > 0 ? "text-red-400" : "text-emerald-400"}`}>
|
<span className={`font-mono tabular-nums ${displayAmount > 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||||
{s.capital_inflow > 0 ? "+" : ""}
|
{displayAmount > 0 ? "+" : ""}
|
||||||
{formatNumber(s.capital_inflow)}
|
{formatNumber(displayAmount)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`font-mono tabular-nums font-semibold min-w-[60px] text-right ${isUp ? "text-red-400" : "text-emerald-400"}`}>
|
<span className={`font-mono tabular-nums font-semibold min-w-[60px] text-right ${isUp ? "text-red-400" : "text-emerald-400"}`}>
|
||||||
{displayPct > 0 ? "+" : ""}
|
{displayPct > 0 ? "+" : ""}
|
||||||
{displayPct.toFixed(2)}%
|
{displayPct.toFixed(2)}%
|
||||||
</span>
|
</span>
|
||||||
|
{displayBreadth && (
|
||||||
|
<span className="font-mono tabular-nums text-xs text-text-secondary min-w-[56px] text-right">
|
||||||
|
{displayBreadth}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{/* Heat score pill */}
|
{/* Heat score pill */}
|
||||||
<span className={`font-mono tabular-nums text-xs font-semibold px-2 py-1 rounded-lg min-w-[36px] text-center ${
|
<span className={`font-mono tabular-nums text-xs font-semibold px-2 py-1 rounded-lg min-w-[36px] text-center ${
|
||||||
isTop3
|
isTop3
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type { RecommendationData } from "@/lib/api";
|
|||||||
|
|
||||||
export default function StockCard({ rec }: { rec: RecommendationData }) {
|
export default function StockCard({ rec }: { rec: RecommendationData }) {
|
||||||
const badge = getLevelBadge(rec.level);
|
const badge = getLevelBadge(rec.level);
|
||||||
|
const aiConviction = rec.llm_score != null ? Math.round(rec.llm_score) : null;
|
||||||
|
|
||||||
// 入场信号标签
|
// 入场信号标签
|
||||||
const signalTypeMap: Record<string, { label: string; style: string }> = {
|
const signalTypeMap: Record<string, { label: string; style: string }> = {
|
||||||
@ -39,6 +40,9 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
|
|||||||
"重点关注": "等待确认,不提前交易",
|
"重点关注": "等待确认,不提前交易",
|
||||||
"观察": "只记录,不主动出手",
|
"观察": "只记录,不主动出手",
|
||||||
};
|
};
|
||||||
|
const evidence = [rec.reasons?.[0], rec.entry_timing, rec.data_freshness]
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2) as string[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card p-4 group">
|
<div className="glass-card p-4 group">
|
||||||
@ -70,12 +74,13 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right shrink-0 ml-3">
|
<div className="text-right shrink-0 ml-3">
|
||||||
<div className="text-[10px] text-text-muted uppercase tracking-wider">
|
<div className="text-[10px] text-text-muted uppercase tracking-wider">结论</div>
|
||||||
AI 决策
|
<div className="text-xs text-text-secondary mt-0.5">{rec.action_plan ?? "观察"}</div>
|
||||||
</div>
|
{aiConviction != null ? (
|
||||||
<div className="text-xs text-text-secondary mt-0.5">
|
<div className="text-[10px] font-mono tabular-nums text-cyan-400/80 mt-0.5">
|
||||||
{rec.action_plan ?? "观察"}
|
AI {aiConviction}/10
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -108,6 +113,15 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
|
|||||||
{rec.review_after_days}日复盘
|
{rec.review_after_days}日复盘
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{aiConviction != null ? (
|
||||||
|
<span className="rounded-md bg-cyan-500/[0.06] border border-cyan-500/10 px-2 py-1 text-cyan-400/80">
|
||||||
|
AI置信 {aiConviction}/10
|
||||||
|
</span>
|
||||||
|
) : rec.score ? (
|
||||||
|
<span className="rounded-md bg-surface-2 px-2 py-1 text-text-secondary">
|
||||||
|
参考分 {rec.score.toFixed(0)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span className={`rounded-md px-2 py-1 ${badge.bg} ${badge.text}`}>
|
<span className={`rounded-md px-2 py-1 ${badge.bg} ${badge.text}`}>
|
||||||
{rec.level}
|
{rec.level}
|
||||||
</span>
|
</span>
|
||||||
@ -115,20 +129,25 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-3 rounded-xl bg-surface-1/60 border border-border-subtle px-3 py-2.5">
|
{evidence.length > 0 && (
|
||||||
<div className="flex items-center justify-between gap-2 mb-2">
|
<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">证据维度</div>
|
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold mb-2">核心证据</div>
|
||||||
<span className="text-[10px] text-text-muted font-mono tabular-nums">
|
<div className="space-y-1.5">
|
||||||
参考 {rec.score.toFixed(0)}
|
{evidence.map((item, index) => (
|
||||||
</span>
|
<div key={`${rec.ts_code}-evidence-${index}`} className="text-[11px] text-text-secondary flex items-start gap-2">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-amber-500/60 mt-[6px] shrink-0" />
|
||||||
|
<span className="leading-relaxed line-clamp-2">{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-2.5 text-[10px] text-text-muted">
|
||||||
|
<span className="rounded-md bg-surface-2 px-2 py-1">规则供需 {Math.round(rec.supply_demand_score ?? 0)}</span>
|
||||||
|
<span className="rounded-md bg-surface-2 px-2 py-1">规则形态 {Math.round(rec.price_action_score ?? 0)}</span>
|
||||||
|
<span className="rounded-md bg-surface-2 px-2 py-1">规则趋势 {Math.round(rec.technical_score ?? 0)}</span>
|
||||||
|
<span className="rounded-md bg-surface-2 px-2 py-1">规则位置 {Math.round(rec.position_score ?? 50)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
)}
|
||||||
<ScoreBar label="供需" value={rec.supply_demand_score ?? 0} weight="证据" />
|
|
||||||
<ScoreBar label="形态" value={rec.price_action_score ?? 0} weight="证据" />
|
|
||||||
<ScoreBar label="趋势" value={rec.technical_score} weight="证据" />
|
|
||||||
<ScoreBar label="位置" value={rec.position_score ?? 50} weight="风控" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Price reference */}
|
{/* Price reference */}
|
||||||
{rec.entry_price && (
|
{rec.entry_price && (
|
||||||
@ -193,10 +212,10 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
|
|||||||
|
|
||||||
<div className="mt-3 border-t border-border-subtle pt-2 flex items-center justify-between gap-3 text-[11px]">
|
<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">
|
<div className="text-text-muted">
|
||||||
推演记录在详情页归档
|
详细推演在详情页归档
|
||||||
{rec.llm_score != null && (
|
{aiConviction != null && (
|
||||||
<span className="ml-2 font-mono tabular-nums text-cyan-400/80">
|
<span className="ml-2 font-mono tabular-nums text-cyan-400/80">
|
||||||
AI {rec.llm_score}/10
|
AI {aiConviction}/10
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -215,25 +234,6 @@ export default function StockCard({ rec }: { rec: RecommendationData }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScoreBar({ label, value, weight }: { label: string; value: number; weight?: string }) {
|
|
||||||
const width = Math.min(value, 100);
|
|
||||||
const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low";
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-[10px] text-text-muted mb-1">
|
|
||||||
<span className="font-medium">{label}{weight ? <span className="text-text-muted/30 ml-0.5">{weight}</span> : null}</span>
|
|
||||||
<span className="font-mono tabular-nums">{value.toFixed(0)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-1.5 bg-surface-3 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full transition-all duration-700 ease-out ${gradientClass}`}
|
|
||||||
style={{ width: `${width}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TrackingMetric({ label, value }: { label: string; value: number | null }) {
|
function TrackingMetric({ label, value }: { label: string; value: number | null }) {
|
||||||
const num = value ?? 0;
|
const num = value ?? 0;
|
||||||
const color = num > 0 ? "text-red-400" : num < 0 ? "text-emerald-400" : "text-text-secondary";
|
const color = num > 0 ? "text-red-400" : num < 0 ? "text-emerald-400" : "text-text-secondary";
|
||||||
|
|||||||
@ -174,6 +174,7 @@ export interface SectorData {
|
|||||||
sector_code: string;
|
sector_code: string;
|
||||||
sector_name: string;
|
sector_name: string;
|
||||||
pct_change: number;
|
pct_change: number;
|
||||||
|
trade_date?: string;
|
||||||
capital_inflow: number;
|
capital_inflow: number;
|
||||||
limit_up_count: number;
|
limit_up_count: number;
|
||||||
days_continuous: number;
|
days_continuous: number;
|
||||||
@ -187,7 +188,13 @@ export interface SectorData {
|
|||||||
main_force_ratio?: number;
|
main_force_ratio?: number;
|
||||||
realtime_pct_change?: number | null;
|
realtime_pct_change?: number | null;
|
||||||
realtime_limit_up_count?: number | null;
|
realtime_limit_up_count?: number | null;
|
||||||
|
realtime_amount?: number | null;
|
||||||
|
realtime_turnover_rate?: number | null;
|
||||||
|
realtime_up_count?: number | null;
|
||||||
|
realtime_down_count?: number | null;
|
||||||
is_realtime?: boolean;
|
is_realtime?: boolean;
|
||||||
|
data_mode?: "realtime_overlay" | "daily_snapshot";
|
||||||
|
structure_trade_date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LatestResult {
|
export interface LatestResult {
|
||||||
@ -272,6 +279,10 @@ export interface StrategySectorFocus {
|
|||||||
heat_score: number;
|
heat_score: number;
|
||||||
pct_change: number;
|
pct_change: number;
|
||||||
limit_up_count: number;
|
limit_up_count: number;
|
||||||
|
turnover_rate?: number;
|
||||||
|
up_count?: number;
|
||||||
|
down_count?: number;
|
||||||
|
data_mode?: string;
|
||||||
view: string;
|
view: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -307,6 +318,7 @@ export interface StrategyIterationReport {
|
|||||||
|
|
||||||
export interface StrategyBoard {
|
export interface StrategyBoard {
|
||||||
trade_date: string;
|
trade_date: string;
|
||||||
|
data_mode?: string;
|
||||||
market_regime: string;
|
market_regime: string;
|
||||||
risk_level: string;
|
risk_level: string;
|
||||||
action_bias: string;
|
action_bias: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user