This commit is contained in:
aaron 2026-04-22 11:56:23 +08:00
parent b699b185fc
commit f3f43a5a5d
33 changed files with 787 additions and 321 deletions

View 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

View File

@ -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()

View File

@ -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日级数据",
} }

View File

@ -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

View File

@ -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,

View File

@ -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()

View File

@ -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

View File

@ -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,

View File

@ -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)}")

View File

@ -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')}"
) )

View File

@ -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}

View File

@ -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: 不看好风险大于收益
- 信号强度""表示把握较大""表示不确定性较高"""

View File

@ -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.

View File

@ -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"
] ]
} }
} }

View File

@ -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"

View File

@ -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>
); );

View File

@ -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 "结合下一个交易时段的量价和板块变化再判断。";
}

View File

@ -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 (

View File

@ -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 ? (

View File

@ -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 "位置偏高,防追高";
}

View File

@ -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

View File

@ -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";

View File

@ -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;