diff --git a/backend/app/__pycache__/config.cpython-313.pyc b/backend/app/__pycache__/config.cpython-313.pyc index 08737201..de9c7742 100644 Binary files a/backend/app/__pycache__/config.cpython-313.pyc and b/backend/app/__pycache__/config.cpython-313.pyc differ diff --git a/backend/app/analysis/sector_realtime.py b/backend/app/analysis/sector_realtime.py new file mode 100644 index 00000000..90e07da2 --- /dev/null +++ b/backend/app/analysis/sector_realtime.py @@ -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 diff --git a/backend/app/api/__pycache__/market.cpython-313.pyc b/backend/app/api/__pycache__/market.cpython-313.pyc index cd8d0984..40b70f23 100644 Binary files a/backend/app/api/__pycache__/market.cpython-313.pyc and b/backend/app/api/__pycache__/market.cpython-313.pyc differ diff --git a/backend/app/api/__pycache__/recommendations.cpython-313.pyc b/backend/app/api/__pycache__/recommendations.cpython-313.pyc index c6cbb806..fdbc92db 100644 Binary files a/backend/app/api/__pycache__/recommendations.cpython-313.pyc and b/backend/app/api/__pycache__/recommendations.cpython-313.pyc differ diff --git a/backend/app/api/__pycache__/sectors.cpython-313.pyc b/backend/app/api/__pycache__/sectors.cpython-313.pyc index 9b3d928d..8f0c39f9 100644 Binary files a/backend/app/api/__pycache__/sectors.cpython-313.pyc and b/backend/app/api/__pycache__/sectors.cpython-313.pyc differ diff --git a/backend/app/api/__pycache__/stocks.cpython-313.pyc b/backend/app/api/__pycache__/stocks.cpython-313.pyc index a740dfb1..79fcd35e 100644 Binary files a/backend/app/api/__pycache__/stocks.cpython-313.pyc and b/backend/app/api/__pycache__/stocks.cpython-313.pyc differ diff --git a/backend/app/api/market.py b/backend/app/api/market.py index e7d2024a..31fc4438 100644 --- a/backend/app/api/market.py +++ b/backend/app/api/market.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends from app.data.tushare_client import tushare_client from app.data import tencent_client 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 router = APIRouter(prefix="/api/market", tags=["market"]) @@ -51,7 +51,8 @@ async def get_overview(): 盘中用腾讯实时行情,盘后用 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 _overview_daily() diff --git a/backend/app/api/recommendations.py b/backend/app/api/recommendations.py index 5d9ad2a1..d3ee8ea5 100644 --- a/backend/app/api/recommendations.py +++ b/backend/app/api/recommendations.py @@ -12,7 +12,8 @@ from app.engine.recommender import ( get_recommendation_history, 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 logger = logging.getLogger(__name__) @@ -151,10 +152,12 @@ async def update_tracking(_admin: dict = Depends(get_current_admin)): @router.get("/status") async def get_scan_status(): """获取当前扫描状态信息""" + latest_trade_date = tushare_client.get_latest_trade_date() + prefer_realtime = should_prefer_realtime_today(latest_trade_date) return { "is_trading": is_trading_hours(), - "scan_mode": "intraday" if is_trading_hours() else "post_market", - "description": "盘中实时扫描(腾讯行情)" if is_trading_hours() else "盘后分析(Tushare日级数据)", + "scan_mode": "intraday" if prefer_realtime else "post_market", + "description": "今日实时分析优先" if prefer_realtime else "盘后分析(Tushare日级数据)", } diff --git a/backend/app/api/sectors.py b/backend/app/api/sectors.py index 22009754..a6ba4fe2 100644 --- a/backend/app/api/sectors.py +++ b/backend/app/api/sectors.py @@ -1,114 +1,22 @@ """板块分析 API""" -import logging - 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.eastmoney_client import get_sector_realtime_ranking from app.data.cache import cache from app.engine.recommender import get_latest_sectors -logger = logging.getLogger(__name__) 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") async def get_hot_sectors(limit: int = 10): """获取热门板块排名(盘中自动补充实时数据)""" sectors = await get_latest_sectors() + sectors = await enrich_sectors_with_realtime(sectors) + trade_date = sectors[0].trade_date if sectors else "" + sectors_data = [ { "sector_code": s.sector_code, @@ -125,11 +33,25 @@ async def get_hot_sectors(limit: int = 10): "pct_trend": s.pct_trend, "turnover_avg": s.turnover_avg, "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] ] - 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 diff --git a/backend/app/api/stocks.py b/backend/app/api/stocks.py index 24f68413..54a15e28 100644 --- a/backend/app/api/stocks.py +++ b/backend/app/api/stocks.py @@ -513,10 +513,10 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")): pass mode_instruction_map = { - "entry": "这是建仓前诊断。重点判断是否值得纳入操作或重点关注,强调触发条件和失效条件。", - "holding": "这是持仓复核。重点判断原有逻辑是否仍成立,是否该继续持有、减仓或退出。", - "review": "这是回撤复盘。重点分析问题出在个股、板块还是市场环境,并给出修正建议。", - "tracking": "这是继续跟踪。重点判断是否保留在观察池、何时升级为可操作或何时移除。", + "entry": "这是建仓前诊断。必须明确当前是可操作、重点关注、观察还是回避,并给出触发与失效边界。", + "holding": "这是持仓复核。必须回答逻辑是否还成立,当前更适合持有、减仓、退出还是继续观察。", + "review": "这是回撤复盘。重点拆清问题来自市场、板块还是个股执行,并提出下一轮修正动作。", + "tracking": "这是继续跟踪。必须说明保留理由、升级条件和移除条件,避免空泛表述。", } mode_label_map = { "entry": "建仓前诊断", @@ -551,6 +551,8 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")): 3. 趋势评分是推荐体系的技术面核心分数(均线排列40+高低点结构35+MA20方向25=满分100),辅助信号计数仅供参考不参与主评分。 4. 位置安全评分高(>80)表示股价处于相对低位,低(<40)表示可能追高。 5. 板块信息和推荐体系信息优先级高于单一技术指标。 +6. 先给结论和动作,再解释原因;不要先铺陈背景再拖到最后才下结论。 +7. 如果证据不足,也要明确给出“观察”或“回避”,不能写成模糊建议。 {freshness_note} 请严格按以下 Markdown 结构输出,不要写成泛泛长文: @@ -558,18 +560,21 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")): ## 当前结论 - 结论: 只能从「可操作 / 重点关注 / 观察 / 回避」中选一个 - 一句话判断: 用一句话解释为什么 +- 当前动作: 只能从「执行 / 等确认 / 继续跟踪 / 暂不参与」中选一个 - 适配模式: 说明更适合启动试错、分歧回流、趋势跟随还是只观察 ## 核心逻辑 - 市场环境: 当前大盘和风格是否支持这只票 - 板块位置: 所属板块是主线、次主线还是观察线 - 个股角色: 龙头 / 跟风 / 独立逻辑 / 非核心 +- 关键证据: 只提最重要的两到三条证据,不要抄原始数据 ## 执行动作 - 触发条件: 什么情况下才可以行动 - 失效条件: 什么情况下放弃 - 仓位建议: 用低 / 中 / 高 或百分比表达 - 适合谁: 适合激进试错、低吸等待、还是不适合参与 +- 跟踪重点: 下一交易时段最该盯住什么 ## 风险清单 - 风险1: @@ -579,11 +584,15 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")): ## 复盘问题 - 如果后续走势不符合预期,优先检查哪两个问题 +## 会诊纪要 +- 用两到三句话总结本次会诊,不要写成长文,不要复制前面的条目 + 要求: - 结论必须明确,不能模糊两可 - 少写形容词,多写交易判断 - 不要重复原始数据 -- 文字保持简洁,避免旧式研报语气""" +- 文字保持简洁,避免旧式研报语气 +- 每个条目尽量一句话说清,不要堆砌长段落""" # ── SSE 流式返回 ── 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( model=settings.deepseek_model, messages=[ - {"role": "system", "content": "你是A股AI投研作战台中的个股会诊模块。你的职责不是写传统长文研报,而是基于市场环境、板块地位、推荐体系评分和跟踪结果,输出可执行、结构化的交易会诊意见。回复必须使用Markdown,结论明确,强调触发条件、失效条件、仓位和风险。"}, + {"role": "system", "content": "你是A股AI投研作战台中的个股会诊模块。你输出的是交易会诊单,不是传统研报。必须先给明确结论,再给执行动作、风险边界和跟踪重点。回复必须使用Markdown,结构严格、结论清晰、语言简短,禁止空泛抒情。"}, {"role": "user", "content": user_msg}, ], max_tokens=1500, diff --git a/backend/app/config.py b/backend/app/config.py index 5d89e409..69c1fe05 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -105,3 +105,27 @@ def is_pre_close() -> bool: now = datetime.now(ZoneInfo("Asia/Shanghai")) t = now.hour * 100 + now.minute 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() diff --git a/backend/app/data/__pycache__/models.cpython-313.pyc b/backend/app/data/__pycache__/models.cpython-313.pyc index 60b466d9..8adf4b9e 100644 Binary files a/backend/app/data/__pycache__/models.cpython-313.pyc and b/backend/app/data/__pycache__/models.cpython-313.pyc differ diff --git a/backend/app/data/models.py b/backend/app/data/models.py index ea4c85a0..30102021 100644 --- a/backend/app/data/models.py +++ b/backend/app/data/models.py @@ -56,6 +56,7 @@ class CapitalFlow(BaseModel): class SectorInfo(BaseModel): sector_code: str sector_name: str + trade_date: str = "" pct_change: float = 0 # 涨跌幅 % capital_inflow: float = 0 # 主力净流入(万元,原始数据来自Tushare亿元×10000) limit_up_count: int = 0 # 涨停数 @@ -70,6 +71,15 @@ class SectorInfo(BaseModel): pct_trend: list[float] = [] # 近5日涨跌幅趋势 turnover_avg: 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): @@ -157,11 +167,16 @@ class StrategySectorFocus(BaseModel): heat_score: float = 0 pct_change: float = 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 = "" class StrategyBoard(BaseModel): trade_date: str + data_mode: str = "daily_snapshot" market_regime: str risk_level: str action_bias: str diff --git a/backend/app/engine/__pycache__/recommender.cpython-313.pyc b/backend/app/engine/__pycache__/recommender.cpython-313.pyc index 1820ec7a..3c5a1ee2 100644 Binary files a/backend/app/engine/__pycache__/recommender.cpython-313.pyc and b/backend/app/engine/__pycache__/recommender.cpython-313.pyc differ diff --git a/backend/app/engine/__pycache__/screener.cpython-313.pyc b/backend/app/engine/__pycache__/screener.cpython-313.pyc index 4d84deb9..501d9be6 100644 Binary files a/backend/app/engine/__pycache__/screener.cpython-313.pyc and b/backend/app/engine/__pycache__/screener.cpython-313.pyc differ diff --git a/backend/app/engine/recommender.py b/backend/app/engine/recommender.py index db61dd8f..86c3b0f0 100644 --- a/backend/app/engine/recommender.py +++ b/backend/app/engine/recommender.py @@ -817,6 +817,7 @@ async def _load_sectors_from_db() -> list[SectorInfo]: sectors.append(SectorInfo( sector_code=r["sector_code"], sector_name=r["sector_name"], + trade_date=r.get("trade_date") or "", pct_change=r["pct_change"] or 0, capital_inflow=r["capital_inflow"] or 0, limit_up_count=r["limit_up_count"] or 0, diff --git a/backend/app/engine/screener.py b/backend/app/engine/screener.py index 6ee74363..46a56749 100644 --- a/backend/app/engine/screener.py +++ b/backend/app/engine/screener.py @@ -29,7 +29,8 @@ from app.analysis.trend_scanner import scan_trend_breakout from app.analysis.signals import generate_signals 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.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 logger = logging.getLogger(__name__) @@ -45,7 +46,8 @@ async def run_screening(trade_date: str = None) -> dict: "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" 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 判断 + # 综合规则边界 + LLM 最终裁决 for rec in recommendations: llm_data = llm_results.get(rec.ts_code) if llm_data: rec.llm_analysis = llm_data.get("analysis", "") + rec.llm_score = float(llm_data.get("conviction", 0) or 0) - # LLM 信号强度转换为分数调整 - # 调整幅度温和,保底不低于 60,避免推荐被过滤掉 - strength = llm_data.get("strength", "中") - llm_signal = llm_data.get("signal", "HOLD") + verdict = llm_data.get("verdict", "watch") + action_plan = llm_data.get("action_plan", "") + conviction = float(llm_data.get("conviction", 6) or 6) + ai_score = conviction * 10 - if llm_signal == "SKIP": - # 降分但保底 60,排序靠后但不消失 - rec.score = max(60, round(rec.score - 15, 1)) - elif llm_signal == "HOLD": - rec.score = max(60, round(rec.score - 5, 1)) - elif llm_signal == "BUY" and strength == "强": - rec.score = round(rec.score + 10, 1) - elif llm_signal == "BUY" and strength == "中": - rec.score = round(rec.score + 5, 1) - else: # BUY + 弱 - pass # 不调整 + if verdict == "execute": + rec.score = round(rec.score * 0.4 + ai_score * 0.6 + 4, 1) + elif verdict == "watch": + rec.score = round(rec.score * 0.5 + ai_score * 0.5 - 2, 1) + else: # skip + rec.score = round(rec.score * 0.45 + ai_score * 0.35 - 18, 1) + + if verdict == "skip": + rec.signal = "HOLD" + rec.action_plan = "观察" + 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) - # 用 LLM 给出的价格替代硬编码价格 + # 用 LLM 给出的价格替代结构化规则价格 if llm_data.get("entry_price"): rec.entry_price = llm_data["entry_price"] if llm_data.get("target_price"): @@ -728,6 +758,11 @@ async def _build_recommendations( if llm_data.get("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 = recommendations[:settings.top_stock_count] logger.info(f"LLM 逐股分析完成, 综合评分后保留 {len(recommendations)} 只") diff --git a/backend/app/llm/__pycache__/prompts.cpython-313.pyc b/backend/app/llm/__pycache__/prompts.cpython-313.pyc index a13b2037..8d726eca 100644 Binary files a/backend/app/llm/__pycache__/prompts.cpython-313.pyc and b/backend/app/llm/__pycache__/prompts.cpython-313.pyc differ diff --git a/backend/app/llm/batch_screener.py b/backend/app/llm/batch_screener.py index 61fe124b..e9363d64 100644 --- a/backend/app/llm/batch_screener.py +++ b/backend/app/llm/batch_screener.py @@ -5,6 +5,7 @@ """ import asyncio +import json import logging import re @@ -21,11 +22,17 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict: market_summary: 市场环境摘要 返回: { - "signal": "BUY"/"HOLD"/"SKIP", - "strength": "强"/"中"/"弱", + "verdict": "execute"/"watch"/"skip", + "action_plan": "可操作"/"重点关注"/"观察", + "conviction": int, + "timing": str, "entry_price": float or None, "target_price": float or None, "stop_loss": float or None, + "trigger_condition": str, + "invalidation_condition": str, + "position_pct": int, + "risk_flag": str, "analysis": str, } """ @@ -36,7 +43,7 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict: stock_text = f"""\ 股票: {candidate['name']}({candidate['ts_code']}) 板块: {candidate.get('sector', '未知')} -量化评分: {candidate.get('quant_score', 0)}/100 +规则参考分: {candidate.get('quant_score', 0)}/100 位置安全: {candidate.get('position_score', 50)}/100 当前价: {candidate.get('current_price', '未知')}""" @@ -56,9 +63,10 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict: { "role": "system", "content": ( - "你是一位专业的A股趋势交易分析师,专注于中短线(1-5日)交易。" - "你根据技术分析结论独立判断入场时机,给出具体的买卖价格建议。" - "不要被量化评分束缚,给出你真实的判断。" + "你是一位A股短线交易裁决员。" + "你的任务是决定这只股票今天是否该进入推荐前列,以及应该归入可操作、重点关注还是观察。" + "不要复述数据,不要写成长文,不要被规则参考分绑架。" + "必须返回合法JSON。" ), }, {"role": "user", "content": user_msg}, @@ -72,69 +80,131 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict: except Exception as e: logger.error(f"LLM 分析 {candidate.get('ts_code')} 失败: {e}") return { - "signal": "HOLD", - "strength": "弱", + "verdict": "watch", + "action_plan": "重点关注", + "conviction": 4, + "timing": "", "entry_price": None, "target_price": None, "stop_loss": None, + "trigger_condition": "", + "invalidation_condition": "", + "position_pct": 0, + "risk_flag": "AI 裁决暂不可用", "analysis": "AI分析暂不可用", } def _parse_single_response(text: str) -> dict: """解析单只股票的 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_match = re.search(r"信号[:\s]*(BUY|HOLD|SKIP)", text) if signal_match: signal = signal_match.group(1) - # 提取信号强度 + verdict = "execute" if signal == "BUY" else "skip" if signal == "SKIP" else "watch" strength = "中" strength_match = re.search(r"信号强度[:\s]*(强|中|弱)", text) if strength_match: strength = strength_match.group(1) + conviction = {"强": 8, "中": 6, "弱": 4}.get(strength, 6) - # 提取买入价 entry_price = None entry_match = re.search(r"买入价[:\s]*(\d+(?:\.\d+)?)", text) if entry_match: entry_price = float(entry_match.group(1)) - - # 提取止盈价 target_price = None target_match = re.search(r"止盈价[:\s]*(\d+(?:\.\d+)?)", text) if target_match: target_price = float(target_match.group(1)) - - # 提取止损价 stop_loss = None stop_match = re.search(r"止损价[:\s]*(\d+(?:\.\d+)?)", text) if stop_match: stop_loss = float(stop_match.group(1)) - - # 提取分析 analysis = "" analysis_match = re.search(r"分析[:\s]*(.+)", text, re.DOTALL) if analysis_match: analysis = analysis_match.group(1).strip() return { - "signal": signal, - "strength": strength, + "verdict": verdict, + "action_plan": {"execute": "可操作", "watch": "重点关注", "skip": "观察"}[verdict], + "conviction": conviction, + "timing": "", "entry_price": entry_price, "target_price": target_price, "stop_loss": stop_loss, + "trigger_condition": "", + "invalidation_condition": "", + "position_pct": 20 if verdict == "execute" else 0, "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( candidates: list[dict], market_summary: str, max_concurrent: int = 3 ) -> dict[str, dict]: """对候选股票逐个做 LLM 分析(控制并发数) - 返回: {ts_code: {"signal", "strength", "entry_price", ...}} + 返回: {ts_code: {"verdict", "action_plan", "conviction", "entry_price", ...}} """ if not settings.deepseek_api_key or not candidates: return {} @@ -149,7 +219,7 @@ async def analyze_candidates_individually( result = await analyze_single_stock(c, market_summary) logger.info( 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('stop_loss')}" ) diff --git a/backend/app/llm/daily_review.py b/backend/app/llm/daily_review.py index b927b58e..7172001d 100644 --- a/backend/app/llm/daily_review.py +++ b/backend/app/llm/daily_review.py @@ -2,7 +2,7 @@ import logging -from app.config import settings +from app.config import settings, today_trade_date logger = logging.getLogger(__name__) @@ -13,7 +13,8 @@ async def generate_review() -> dict: from app.data import tencent_client 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() @@ -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}%), " 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 = "" @@ -58,6 +59,7 @@ async def generate_review() -> dict: user_msg = f"""请根据以下数据生成今日A股市场复盘报告(中文): 日期: {trade_date} +Tushare 最新交易日: {latest_trade_date} {market_summary} {index_summary} {sector_summary} diff --git a/backend/app/llm/prompts.py b/backend/app/llm/prompts.py index 38c16cea..700934d4 100644 --- a/backend/app/llm/prompts.py +++ b/backend/app/llm/prompts.py @@ -122,32 +122,37 @@ ANALYSIS_USER_TEMPLATE = """\ # ── AI 逐股筛选 Prompt ── SINGLE_STOCK_ANALYSIS_PROMPT = """\ -你是一位专业的A股趋势交易分析师,专注于中短线交易(持仓1-5个交易日)。 +你是一位A股中短线交易裁决员,不是量化打分解释器。你的职责是基于给定的市场、板块、量价和位置结论,决定这只股票今天应不应该进入推荐池前列,以及应该归入什么动作级别。 -量化系统已通过多轮筛选认定该股票具备投资价值,请你基于以下技术分析结论,独立判断入场时机。 +你的原则: +1. 量化分数只是参考,不是最终答案 +2. 如果板块地位、量价质量、位置或时机不匹配,可以直接否决高分股 +3. 如果股票具备明确触发与失效边界,即使量化分不是最高,也可以提升优先级 +4. 输出的是交易裁决单,不是研报 -你的任务: -1. 综合趋势、量价、技术指标和位置,判断当前是否适合介入 -2. 如果适合介入,给出具体的买入价位、止盈价和止损价 -3. 评估信号强度 +请严格输出 JSON,不要输出 Markdown,不要添加多余解释。字段如下: +{ + "verdict": "execute | watch | skip", + "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线和技术指标的分析结论,不是原始数据 -- 请独立判断,不要被量化评分影响太多 -- 止盈空间通常3-8%,止损空间通常3-5% -- 中短线交易,重点关注1-5日的走势 +裁决标准: +- execute: 今天具备执行条件或非常接近执行条件,可以进入推荐前列 +- watch: 逻辑还在,但需要等待确认,不应直接作为首选执行标的 +- skip: 当前不适合进入推荐前列,宁可错过也不主动参与 -请严格按以下格式输出: - -信号: BUY/HOLD/SKIP -信号强度: 强/中/弱 -买入价: XX.XX(建议入场价位,基于当前价微调) -止盈价: XX.XX(目标价位,给出合理空间) -止损价: XX.XX(跌破此价离场) -分析: 3-5句话,说明核心逻辑、入场理由和主要风险 - -说明: -- BUY: 看好,建议在买入价附近介入 -- HOLD: 观望,等待更好的时机 -- SKIP: 不看好,风险大于收益 -- 信号强度"强"表示把握较大,"弱"表示不确定性较高""" +补充要求: +- conviction 必须是 1-10 的整数 +- position_pct 返回 0-35 的整数;如果不适合参与,就返回 0 +- 没有把握时优先给 watch 或 skip +- trigger_condition 和 invalidation_condition 必须可执行,不能写空话""" diff --git a/backend/app/llm/strategy_board.py b/backend/app/llm/strategy_board.py index 3f9eb0bb..532cf6e2 100644 --- a/backend/app/llm/strategy_board.py +++ b/backend/app/llm/strategy_board.py @@ -6,7 +6,8 @@ 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 ( MarketTemperature, Recommendation, @@ -19,6 +20,35 @@ from app.data.models import ( 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: """生成今日市场作战面板。""" 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") recommendations = latest.get("recommendations", []) sectors = await get_latest_sectors() + sectors = await enrich_sectors_with_realtime(sectors) performance = await get_performance_stats() from app.llm.strategy_iteration import build_strategy_iteration_report iteration_report = await build_strategy_iteration_report(limit=50, include_llm=include_llm) @@ -59,11 +90,18 @@ def _build_rule_board( performance: dict, ) -> StrategyBoard: 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) actionable = [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 = ( round(sum(r.score for r in recommendations) / len(recommendations), 1) if recommendations else 0 @@ -74,11 +112,13 @@ def _build_rule_board( watch_sectors = [_sector_focus(s) for s in sectors[:5]] avoid_rules = _build_avoid_rules(temp, sectors, recommendations) iteration_notes = _build_iteration_notes(performance, recommendations) + mode_prefix = "今日实时视角:" if prefer_realtime else "" summary = ( - f"{market_regime},风险等级{risk_level}。" + f"{mode_prefix}{market_regime},风险等级{risk_level}。" f"当前 {len(recommendations)} 只入选,其中 {len(actionable)} 只可操作、" f"{len(watch)} 只重点关注,平均分 {avg_score}。" + f"{'主线活跃板块 ' + ' / '.join(s.sector_name for s in strong_sectors[:3]) + '。' if strong_sectors else '板块尚未形成强共振,优先等确认。'}" ) metrics = { @@ -94,6 +134,7 @@ def _build_rule_board( return StrategyBoard( trade_date=trade_date, + data_mode=data_mode, market_regime=market_regime, risk_level=risk_level, action_bias=action_bias, @@ -125,11 +166,17 @@ def _classify_market( def _choose_strategy_mode( temp: float, sectors: list[SectorInfo], recommendations: list[Recommendation] ) -> str: - early_mid = [s for s in sectors[:5] if s.stage in ("early", "mid")] - if temp >= 60 and early_mid: - return "主线突破 + 回踩确认" - if temp >= 45: - return "精选回踩,降低追高" + top = sectors[:5] + strong = [s for s in top if _sector_pct(s) >= 2 and _sector_breadth(s) > 0] + active = [s for s in top if _sector_pct(s) >= 1 and (_sector_turnover(s) >= 2 or s.limit_up_count >= 2)] + end_stage = [s for s in top if s.stage == "end" and _sector_pct(s) > 0] + + if temp >= 65 and len(strong) >= 2: + return "顺主线做强势确认" + if temp >= 55 and active: + return "围绕强板块做回踩确认" + if temp < 45 or end_stage: + return "缩仓观察,回避后排追高" if recommendations: return "观察池跟踪,等待触发" return "防守观察" @@ -159,16 +206,37 @@ def _build_strategy_focus( if sectors: main = sectors[0] + sector_pct = _sector_pct(main) + breadth = _sector_breadth(main) + turnover = _sector_turnover(main) focus.append(StrategyFocus( 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: focus.append(StrategyFocus( label="防守优先", description="市场温度不足,推荐只作为观察池,不宜扩大仓位。", )) + elif sectors and _sector_pct(sectors[0]) < 1: + focus.append(StrategyFocus( + label="等一致性强化", + description="板块领涨幅度仍有限,优先等主线扩散和个股触发后再出手。", + )) return focus @@ -185,9 +253,19 @@ def _sector_focus(sector: SectorInfo) -> StrategySectorFocus: sector_name=sector.sector_name, stage=sector.stage, heat_score=sector.heat_score, - pct_change=sector.pct_change, + pct_change=_sector_pct(sector), 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] ) -> list[str]: rules = [] + top = sectors[:5] if temp < 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("板块进入末期时,降低同板块追高标的权重。") + 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): rules.append("位置安全分低于35的标的,只观察不主动追入。") if not rules: diff --git a/backend/astock.db b/backend/astock.db index 31ffc2d1..ccf34c11 100644 Binary files a/backend/astock.db and b/backend/astock.db differ diff --git a/frontend/.next/app-build-manifest.json b/frontend/.next/app-build-manifest.json index bce6a549..ef123f7d 100644 --- a/frontend/.next/app-build-manifest.json +++ b/frontend/.next/app-build-manifest.json @@ -70,11 +70,6 @@ "static/chunks/webpack.js", "static/chunks/main-app.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" ] } } \ No newline at end of file diff --git a/frontend/.next/server/app-paths-manifest.json b/frontend/.next/server/app-paths-manifest.json index 32c12767..21ce9557 100644 --- a/frontend/.next/server/app-paths-manifest.json +++ b/frontend/.next/server/app-paths-manifest.json @@ -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)/dashboard/page": "app/(auth)/dashboard/page.js", "/(auth)/chat/page": "app/(auth)/chat/page.js", "/(public)/login/page": "app/(public)/login/page.js", "/(public)/page": "app/(public)/page.js" diff --git a/frontend/src/app/(auth)/dashboard/page.tsx b/frontend/src/app/(auth)/dashboard/page.tsx index 6c39b3a5..548bc231 100644 --- a/frontend/src/app/(auth)/dashboard/page.tsx +++ b/frontend/src/app/(auth)/dashboard/page.tsx @@ -350,18 +350,34 @@ function MissionControl({ const risks = board?.avoid_rules?.length ? board.avoid_rules : ["等待市场状态和推荐结果更新后生成风险约束。"]; const primaryQueue = (actionable.length ? actionable : watch).slice(0, 3); const laneTitle = actionable.length ? "优先执行" : watch.length ? "候选观察" : "等待信号"; - const strategyName = strategyProfile?.name ?? board?.recommended_mode ?? "待定"; - const strategyHint = strategyProfile?.description ?? "系统判断今天更适合采用的出手方式"; - const riskText = risks.slice(0, 2).join(" / "); + const strategyName = strategyProfile?.name && strategyProfile.name !== "当前推荐策略" + ? strategyProfile.name + : board?.recommended_mode ?? "待定"; + const strategyHint = strategyProfile?.description ?? "结合市场状态与主线强弱得出的出手方式"; + const isRealtimeBoard = board?.data_mode === "realtime_today"; + const topSectorEvidence = topSectors.map((sector) => ({ + key: sector.sector_code, + name: sector.sector_name, + pct: sector.realtime_pct_change ?? sector.pct_change, + breadth: sector.realtime_up_count != null && sector.realtime_down_count != null + ? `${sector.realtime_up_count}/${sector.realtime_down_count}` + : null, + turnover: sector.realtime_turnover_rate, + })); return ( -
+
{board?.summary ?? "系统尚未生成今日作战结论。触发扫描后,将基于市场温度、板块主线、推荐生命周期和策略复盘生成操作框架。"}
+ {board?.trade_date && ( +- 这里把 AI 选股结果拆成四个动作池:能操作的先盯触发,未确认的继续等待,已推荐的持续跟踪,结束后的样本进入策略迭代。 + 先看可操作和重点关注,再看跟踪结果,结束样本自动回流到策略迭代。
判断当前主线、板块阶段、资金持续性和领涨股强度 - {hasRealtime && · 实时数据} + {hasRealtime && · 盘中实时优先}
+ {hasRealtime && dataMode === "realtime_overlay" && ( ++ 盘中模式下,涨幅、成交额、上涨/下跌家数与领涨股为实时覆盖;阶段、资金连续性等结构字段仍基于 + {structureTradeDate || "最近交易日"} + 的板块快照。 +
+ )}