From e1d4916615cdc599d38880abaeee2557c8eede77 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Tue, 28 Apr 2026 11:56:40 +0800 Subject: [PATCH] 1 --- backend/app/config.py | 10 +- backend/app/engine/recommender.py | 10 +- backend/app/engine/screener.py | 126 ++++++++++++- backend/app/llm/strategy_selector.py | 178 +++++++++--------- frontend/src/app/(auth)/dashboard/page.tsx | 55 ++++-- .../src/app/(auth)/recommendations/page.tsx | 100 +++++++--- frontend/src/lib/api.ts | 8 + 7 files changed, 342 insertions(+), 145 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 62863631..27edf2c0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -29,11 +29,13 @@ class Settings(BaseSettings): # 筛选参数 top_sector_count: int = 5 # 关注板块数量 - top_stock_count: int = 20 # 进入技术面筛选的个股数 - candidate_pool_limit: int = 120 # 多路召回后的候选池上限 - llm_prefilter_limit: int = 36 # LLM 初筛保留数量 + top_stock_count: int = 6 # 最终推荐输出上限 + candidate_pool_limit: int = 90 # 多路召回后的候选池上限 + llm_prefilter_limit: int = 24 # LLM 初筛保留数量 llm_prefilter_max_concurrent: int = 6 - llm_final_limit: int = 14 # LLM 深裁决池上限 + llm_final_limit: int = 10 # LLM 深裁决池上限 + actionable_limit: int = 3 # 最多可操作标的 + watch_limit: int = 5 # 最多重点关注标的 min_turnover_rate: float = 2.0 # 最小换手率 % max_turnover_rate: float = 30.0 # 最大换手率 % min_circ_mv: float = 50.0 # 最小流通市值(亿) diff --git a/backend/app/engine/recommender.py b/backend/app/engine/recommender.py index 931e1e0e..1fd7f717 100644 --- a/backend/app/engine/recommender.py +++ b/backend/app/engine/recommender.py @@ -12,6 +12,7 @@ from functools import partial from datetime import datetime, timedelta from app.engine.screener import run_screening from app.data.models import Recommendation, MarketTemperature, SectorInfo +from app.llm.strategy_selector import get_strategy_profile_by_id from app.db.database import get_db from app.db import tables @@ -928,10 +929,11 @@ async def _load_today_from_db() -> dict: "hot_sectors": [], "capital_filtered": [], "recommendations": recommendations, - "strategy_profile": { - "strategy_id": recommendations[0].strategy if recommendations else "trend_breakout", - "name": "当前推荐策略", - } if recommendations else None, + "strategy_profile": ( + get_strategy_profile_by_id(recommendations[0].strategy).model_dump() + if recommendations + else None + ), } except Exception as e: logger.error(f"从数据库加载推荐失败: {e}") diff --git a/backend/app/engine/screener.py b/backend/app/engine/screener.py index 88aa4f32..cf56b9c7 100644 --- a/backend/app/engine/screener.py +++ b/backend/app/engine/screener.py @@ -39,7 +39,7 @@ from app.analysis.intraday import ( from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Recommendation from app.config import settings, 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 StrategyProfile, select_strategy_profile logger = logging.getLogger(__name__) @@ -165,6 +165,13 @@ async def run_screening(trade_date: str = None) -> dict: if _is_main_theme_recommendation(r) and r.score >= strategy_profile.min_score ] + recommendations = _finalize_battle_plan( + recommendations=recommendations, + hot_sectors=hot_sectors, + market_temp=market_temp, + strategy_profile=strategy_profile, + ) + logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===") for r in recommendations[:5]: signal_map = {"breakout": "突破型", "breakout_confirm": "确认型", "pullback": "回踩型", "launch": "启动型", "reversal": "反转型"} @@ -452,6 +459,114 @@ def _route_recall_weight(route: str, item: dict) -> float: return 0 +def _finalize_battle_plan( + recommendations: list[Recommendation], + hot_sectors: list[SectorInfo], + market_temp: MarketTemperature, + strategy_profile: StrategyProfile, +) -> list[Recommendation]: + if not recommendations: + return [] + + top_sector_names = {sector.sector_name for sector in hot_sectors[: max(strategy_profile.target_focus_sectors, 1)]} + positive_top_sector_count = sum( + 1 + for sector in hot_sectors[: max(strategy_profile.target_focus_sectors + 1, 2)] + if (sector.realtime_pct_change if sector.realtime_pct_change is not None else sector.pct_change) > 0 + ) + + allow_trading = strategy_profile.allow_trading and market_temp.temperature >= 40 + if market_temp.temperature < 35: + allow_trading = False + if positive_top_sector_count == 0 and market_temp.temperature < 55: + allow_trading = False + + actionable_limit = min(settings.actionable_limit, strategy_profile.actionable_limit) + watch_limit = min(settings.watch_limit, strategy_profile.watch_limit) + + if not allow_trading: + actionable_limit = 0 + watch_limit = min(watch_limit, 3) + elif positive_top_sector_count <= 1 and market_temp.temperature < 60: + actionable_limit = min(actionable_limit, 1) + elif market_temp.temperature < 50: + actionable_limit = min(actionable_limit, 2) + + for rec in recommendations: + is_main_theme = rec.sector in top_sector_names or _is_main_theme_recommendation(rec) + if not allow_trading and rec.action_plan == "可操作": + rec.action_plan = "重点关注" if is_main_theme else "观察" + rec.lifecycle_status = "candidate" + rec.signal = "HOLD" + rec.suggested_position_pct = 0 + elif rec.action_plan == "可操作" and not is_main_theme: + rec.action_plan = "重点关注" + rec.lifecycle_status = "candidate" + rec.signal = "HOLD" + rec.suggested_position_pct = min(rec.suggested_position_pct or 0, 10) + + if rec.action_plan == "重点关注" and not is_main_theme and rec.score < strategy_profile.buy_threshold + 2: + rec.action_plan = "观察" + rec.lifecycle_status = "candidate" + rec.signal = "HOLD" + rec.suggested_position_pct = 0 + + def rank_key(rec: Recommendation) -> tuple: + plan_rank = {"可操作": 2, "重点关注": 1, "观察": 0}.get(rec.action_plan or "观察", 0) + llm_score = rec.llm_score if rec.llm_score is not None else 0 + sector_rank = next( + ( + max(0, 20 - idx) + for idx, sector in enumerate(hot_sectors) + if sector.sector_name == rec.sector + ), + 0, + ) + return ( + plan_rank, + 1 if rec.sector in top_sector_names else 0, + 1 if _is_main_theme_recommendation(rec) else 0, + llm_score, + sector_rank, + rec.score, + ) + + actionable = sorted([rec for rec in recommendations if rec.action_plan == "可操作"], key=rank_key, reverse=True) + watch = sorted([rec for rec in recommendations if rec.action_plan == "重点关注"], key=rank_key, reverse=True) + observe = sorted([rec for rec in recommendations if rec.action_plan == "观察"], key=rank_key, reverse=True) + + kept_actionable = actionable[:actionable_limit] + overflow_actionable = actionable[actionable_limit:] + for rec in overflow_actionable: + rec.action_plan = "重点关注" if allow_trading else "观察" + rec.lifecycle_status = "candidate" + rec.signal = "HOLD" + if not allow_trading: + rec.suggested_position_pct = 0 + watch.extend(overflow_actionable) + watch = sorted(watch, key=rank_key, reverse=True) + + kept_watch = watch[:watch_limit] + overflow_watch = watch[watch_limit:] + for rec in overflow_watch: + rec.action_plan = "观察" + rec.lifecycle_status = "candidate" + rec.signal = "HOLD" + rec.suggested_position_pct = 0 + observe.extend(overflow_watch) + observe = sorted(observe, key=rank_key, reverse=True) + + total_limit = max(settings.top_stock_count, actionable_limit + watch_limit) + if total_limit <= len(kept_actionable) + len(kept_watch): + return (kept_actionable + kept_watch)[:total_limit] + + remain = total_limit - len(kept_actionable) - len(kept_watch) + kept_observe = observe[:remain] + final_list = kept_actionable + kept_watch + kept_observe + final_list.sort(key=rank_key, reverse=True) + return final_list[: settings.top_stock_count] + + async def _build_recommendations( candidates: list[dict], market_temp: MarketTemperature, @@ -1221,15 +1336,18 @@ def _build_trade_plan( "reversal": "放量反转", }.get(signal_type, "技术信号") - if market_temp.temperature < 35 or sector_stage in ("end",): + if market_temp.temperature < 40 or sector_stage in ("end",): action_plan = "观察" lifecycle_status = "candidate" - elif score >= 78 and market_temp.temperature >= 55 and sector_stage in ("early", "mid"): + elif score >= 84 and market_temp.temperature >= 62 and sector_stage == "early": action_plan = "可操作" lifecycle_status = "actionable" - elif score >= 65: + elif score >= 72 and market_temp.temperature >= 48 and sector_stage in ("early", "mid"): action_plan = "重点关注" lifecycle_status = "candidate" + elif score >= 64 and sector_stage != "end": + action_plan = "观察" + lifecycle_status = "candidate" else: action_plan = "观察" lifecycle_status = "candidate" diff --git a/backend/app/llm/strategy_selector.py b/backend/app/llm/strategy_selector.py index f1c9c53d..721fd01c 100644 --- a/backend/app/llm/strategy_selector.py +++ b/backend/app/llm/strategy_selector.py @@ -24,10 +24,97 @@ class StrategyProfile(BaseModel): min_score: float buy_threshold: float max_position_pct: float + allow_trading: bool = True + actionable_limit: int = 2 + watch_limit: int = 4 + target_focus_sectors: int = 2 + market_stance: str = "" + decision_note: str = "" notes: list[str] = [] generated_by: str = "rules" +def get_strategy_profile_by_id(strategy_id: str) -> StrategyProfile: + normalized = strategy_id or "defensive_watch" + if normalized == "trend_breakout": + normalized = "breakout_attack" + + actionable_cap = max(0, settings.actionable_limit) + watch_cap = max(0, settings.watch_limit) + + profiles = { + "breakout_attack": StrategyProfile( + strategy_id="breakout_attack", + name="主线突破", + description="市场偏强,优先寻找主线板块内的突破和突破确认。", + entry_signal_priority=["breakout", "breakout_confirm", "launch", "pullback", "reversal"], + score_weights={"supply_demand": 0.45, "price_action": 0.35, "trend": 0.20}, + min_score=62, + buy_threshold=66, + max_position_pct=30, + allow_trading=True, + actionable_limit=min(3, actionable_cap), + watch_limit=min(4, watch_cap), + target_focus_sectors=2, + market_stance="主线进攻", + decision_note="只处理最强主线前排,不扩散到跟风和后排。", + notes=["优先做主线早中期板块", "放量突破优先于回踩低吸"], + ), + "pullback_rotation": StrategyProfile( + strategy_id="pullback_rotation", + name="回踩轮动", + description="市场震荡分化,优先做回踩支撑和板块轮动中的低吸确认。", + entry_signal_priority=["pullback", "breakout_confirm", "launch", "breakout", "reversal"], + score_weights={"supply_demand": 0.40, "price_action": 0.30, "trend": 0.30}, + min_score=60, + buy_threshold=63, + max_position_pct=20, + allow_trading=True, + actionable_limit=min(2, actionable_cap), + watch_limit=min(5, watch_cap), + target_focus_sectors=2, + market_stance="轮动低吸", + decision_note="先等回踩承接和板块回流,再决定是否出手。", + notes=["降低追高仓位", "更看重位置安全和回踩承接"], + ), + "launch_probe": StrategyProfile( + strategy_id="launch_probe", + name="启动试错", + description="市场偏弱,适合少量观察启动型和反转型机会,不做强追涨。", + entry_signal_priority=["launch", "reversal", "pullback", "breakout_confirm", "breakout"], + score_weights={"supply_demand": 0.35, "price_action": 0.35, "trend": 0.30}, + min_score=58, + buy_threshold=61, + max_position_pct=10, + allow_trading=True, + actionable_limit=min(1, actionable_cap), + watch_limit=min(4, watch_cap), + target_focus_sectors=1, + market_stance="轻仓试错", + decision_note="只有极少数启动确认标的值得小仓试错。", + notes=["仅做小仓位试错", "突破型需要更强板块一致性才可介入"], + ), + "defensive_watch": StrategyProfile( + strategy_id="defensive_watch", + name="防守观察", + description="市场退潮,系统以观察池为主,不主动扩大出手。", + entry_signal_priority=["pullback", "launch", "reversal", "breakout_confirm", "breakout"], + score_weights={"supply_demand": 0.35, "price_action": 0.40, "trend": 0.25}, + min_score=56, + buy_threshold=64, + max_position_pct=5, + allow_trading=False, + actionable_limit=0, + watch_limit=min(3, watch_cap), + target_focus_sectors=1, + market_stance="防守观察", + decision_note="今天不主动出手,只保留少量观察名单。", + notes=["原则上只保留观察池", "等待市场温度修复后再转入主动进攻"], + ), + } + return profiles.get(normalized, profiles["defensive_watch"]).model_copy(deep=True) + + async def select_strategy_profile( market_temp: MarketTemperature | None, hot_sectors: list[SectorInfo], @@ -53,55 +140,15 @@ def _select_rule_profile( late_count = sum(1 for s in hot_sectors[:5] if s.stage in ("late", "end")) if temp >= 65 and early_count >= 1: - return StrategyProfile( - strategy_id="breakout_attack", - name="主线突破", - description="市场偏强,优先寻找主线板块内的突破和突破确认。", - entry_signal_priority=["breakout", "breakout_confirm", "launch", "pullback", "reversal"], - score_weights={"supply_demand": 0.45, "price_action": 0.35, "trend": 0.20}, - min_score=62, - buy_threshold=66, - max_position_pct=30, - notes=["优先做主线早中期板块", "放量突破优先于回踩低吸"], - ) + return get_strategy_profile_by_id("breakout_attack") if temp >= 45 and late_count < 2: - return StrategyProfile( - strategy_id="pullback_rotation", - name="回踩轮动", - description="市场震荡分化,优先做回踩支撑和板块轮动中的低吸确认。", - entry_signal_priority=["pullback", "breakout_confirm", "launch", "breakout", "reversal"], - score_weights={"supply_demand": 0.40, "price_action": 0.30, "trend": 0.30}, - min_score=60, - buy_threshold=63, - max_position_pct=20, - notes=["降低追高仓位", "更看重位置安全和回踩承接"], - ) + return get_strategy_profile_by_id("pullback_rotation") if temp >= 30: - return StrategyProfile( - strategy_id="launch_probe", - name="启动试错", - description="市场偏弱,适合少量观察启动型和反转型机会,不做强追涨。", - entry_signal_priority=["launch", "reversal", "pullback", "breakout_confirm", "breakout"], - score_weights={"supply_demand": 0.35, "price_action": 0.35, "trend": 0.30}, - min_score=58, - buy_threshold=61, - max_position_pct=10, - notes=["仅做小仓位试错", "突破型需要更强板块一致性才可介入"], - ) + return get_strategy_profile_by_id("launch_probe") - return StrategyProfile( - strategy_id="defensive_watch", - name="防守观察", - description="市场退潮,系统以观察池为主,不主动扩大出手。", - entry_signal_priority=["pullback", "launch", "reversal", "breakout_confirm", "breakout"], - score_weights={"supply_demand": 0.35, "price_action": 0.40, "trend": 0.25}, - min_score=56, - buy_threshold=64, - max_position_pct=5, - notes=["原则上只保留观察池", "等待市场温度修复后再转入主动进攻"], - ) + return get_strategy_profile_by_id("defensive_watch") async def _select_llm_profile( @@ -157,48 +204,7 @@ async def _select_llm_profile( return None selected = _select_rule_profile(market_temp, hot_sectors, intraday) if selected.strategy_id != strategy_id: - selected = { - "breakout_attack": StrategyProfile( - strategy_id="breakout_attack", - name="主线突破", - description="市场偏强,优先寻找主线板块内的突破和突破确认。", - entry_signal_priority=["breakout", "breakout_confirm", "launch", "pullback", "reversal"], - score_weights={"supply_demand": 0.45, "price_action": 0.35, "trend": 0.20}, - min_score=62, - buy_threshold=66, - max_position_pct=30, - ), - "pullback_rotation": StrategyProfile( - strategy_id="pullback_rotation", - name="回踩轮动", - description="市场震荡分化,优先做回踩支撑和板块轮动中的低吸确认。", - entry_signal_priority=["pullback", "breakout_confirm", "launch", "breakout", "reversal"], - score_weights={"supply_demand": 0.40, "price_action": 0.30, "trend": 0.30}, - min_score=60, - buy_threshold=63, - max_position_pct=20, - ), - "launch_probe": StrategyProfile( - strategy_id="launch_probe", - name="启动试错", - description="市场偏弱,适合少量观察启动型和反转型机会,不做强追涨。", - entry_signal_priority=["launch", "reversal", "pullback", "breakout_confirm", "breakout"], - score_weights={"supply_demand": 0.35, "price_action": 0.35, "trend": 0.30}, - min_score=58, - buy_threshold=61, - max_position_pct=10, - ), - "defensive_watch": StrategyProfile( - strategy_id="defensive_watch", - name="防守观察", - description="市场退潮,系统以观察池为主,不主动扩大出手。", - entry_signal_priority=["pullback", "launch", "reversal", "breakout_confirm", "breakout"], - score_weights={"supply_demand": 0.35, "price_action": 0.40, "trend": 0.25}, - min_score=56, - buy_threshold=64, - max_position_pct=5, - ), - }[strategy_id] + selected = get_strategy_profile_by_id(strategy_id) delta = int(data.get("buy_threshold_delta", 0)) delta = max(-3, min(3, delta)) diff --git a/frontend/src/app/(auth)/dashboard/page.tsx b/frontend/src/app/(auth)/dashboard/page.tsx index 23437d30..6a4569d4 100644 --- a/frontend/src/app/(auth)/dashboard/page.tsx +++ b/frontend/src/app/(auth)/dashboard/page.tsx @@ -171,13 +171,13 @@ export default function DashboardPage() { const observe = recommendations.filter((rec) => !["可操作", "重点关注"].includes(rec.action_plan ?? "")); const marketSummary = useMemo( - () => buildMarketSummary(marketTemperature, strategyBoard, scanStatus, actionable.length, watch.length), - [actionable.length, marketTemperature, scanStatus, strategyBoard, watch.length] + () => buildMarketSummary(data?.strategy_profile ?? null, marketTemperature, strategyBoard, scanStatus, actionable.length, watch.length), + [actionable.length, data?.strategy_profile, marketTemperature, scanStatus, strategyBoard, watch.length] ); const todayActions = useMemo( - () => buildActionGuides(strategyBoard, marketTemperature, actionable, watch, observe, sectors), - [actionable, marketTemperature, observe, sectors, strategyBoard, watch] + () => buildActionGuides(data?.strategy_profile ?? null, strategyBoard, marketTemperature, actionable, watch, observe, sectors), + [actionable, data?.strategy_profile, marketTemperature, observe, sectors, strategyBoard, watch] ); const focusQueue = actionable.length ? actionable : watch.length ? watch : recommendations; @@ -310,8 +310,8 @@ function DecisionHero({ - - + + @@ -630,6 +630,7 @@ function FreshnessPill({ label, value }: { label: string; value: string }) { } function buildMarketSummary( + strategyProfile: LatestResult["strategy_profile"], marketTemperature: MarketTemperatureData | null, board: StrategyBoard | null, scanStatus: ScanStatus | null, @@ -637,35 +638,50 @@ function buildMarketSummary( watchCount: number ) { const temp = marketTemperature?.temperature ?? board?.metrics.temperature ?? 0; - const headline = - board?.market_regime ?? - (temp >= 70 ? "市场偏强,可以围绕主线进攻" : - temp >= 50 ? "市场可做,但只做确认机会" : - temp >= 30 ? "市场分化,轻仓试错" : - "市场偏弱,以观察为主"); + const allowTrading = strategyProfile?.allow_trading ?? actionableCount > 0; + const headline = !allowTrading + ? "今天不主动出手,先防守观察" + : board?.market_regime ?? + (temp >= 70 ? "市场偏强,可以围绕主线进攻" : + temp >= 50 ? "市场可做,但只做确认机会" : + temp >= 30 ? "市场分化,轻仓试错" : + "市场偏弱,以观察为主"); const detail = + strategyProfile?.decision_note ?? board?.summary ?? (scanStatus?.is_trading ? "当前使用盘中实时数据判断市场,重点在节奏、仓位和主线强弱,而不是静态分数。" : "当前以收盘后数据为主,适合复盘主线、更新候选池和准备下一交易日。"); const canDo = [ - actionableCount > 0 ? `优先只看 ${actionableCount} 只可操作标的,避免在杂波里分散注意力。` : "没有明确可操作标的时,只保留观察,不主动开新仓。", + !allowTrading + ? "今天只做观察和等待,不把普通异动抬升为买入机会。" + : actionableCount > 0 + ? `优先只看 ${actionableCount} 只可操作标的,避免在杂波里分散注意力。` + : "没有明确可操作标的时,只保留观察,不主动开新仓。", watchCount > 0 ? `重点关注 ${watchCount} 只等待确认的标的,等放量、回流或分歧转一致。` : "把注意力放在最强板块前排,而不是平均分配给所有候选。", - board?.position_suggestion ?? (temp >= 50 ? "仓位可以试错,但不要脱离主线。" : "控制仓位,等待更强确认。"), + board?.position_suggestion ?? (strategyProfile?.max_position_pct ? `总仓位上限先按 ${strategyProfile.max_position_pct}% 控制。` : temp >= 50 ? "仓位可以试错,但不要脱离主线。" : "控制仓位,等待更强确认。"), ]; const cannotDo = [ board?.avoid_rules?.[0] ?? "不要追后排、跟风和没有板块支撑的个股。", board?.avoid_rules?.[1] ?? (temp < 50 ? "不要因为个别异动就误判成全面回暖。" : "不要把盘中脉冲当成全天主线。"), - temp < 40 ? "不要扩大仓位做逆势试错。" : "不要脱离纪律随意切换题材。", + !allowTrading || temp < 40 ? "不要扩大仓位做逆势试错。" : "不要脱离纪律随意切换题材。", ]; - return { headline, detail, canDo, cannotDo }; + return { + headline, + detail, + canDo, + cannotDo, + modeLabel: strategyProfile?.market_stance || board?.recommended_mode || "等待更新", + positionLabel: board?.position_suggestion || (strategyProfile?.max_position_pct ? `${strategyProfile.max_position_pct}% 以内` : "等待更新"), + }; } function buildActionGuides( + strategyProfile: LatestResult["strategy_profile"], board: StrategyBoard | null, marketTemperature: MarketTemperatureData | null, actionable: RecommendationData[], @@ -677,9 +693,12 @@ function buildActionGuides( const backupSectors = sectors.slice(0, 2).map((sector) => sector.sector_name); const focusSectors = topSectors.length ? topSectors : backupSectors; const temp = marketTemperature?.temperature ?? board?.metrics.temperature ?? 0; + const allowTrading = strategyProfile?.allow_trading ?? actionable.length > 0; const priority = [ - actionable[0] + !allowTrading + ? "先等市场重新给出清晰主线和承接,不主动寻找执行票。" + : actionable[0] ? `先盯 ${actionable[0].name}${actionable[0].trigger_condition ? `,触发条件是 ${actionable[0].trigger_condition}` : " 的确认信号"}。` : focusSectors[0] ? `主看 ${focusSectors[0]} 前排是否继续强化,再决定是否参与。` @@ -687,7 +706,7 @@ function buildActionGuides( focusSectors[1] ? `把 ${focusSectors.slice(0, 2).join("、")} 作为主线池,不要同时追太多方向。` : "今天只围绕一条最强主线做决策,避免来回切换。", - temp >= 50 ? "优先做分歧后的回流和确认,不做无量冲高。" : "优先保守观察,只有最强确认才考虑出手。", + !allowTrading ? "今天优先保守观察,不做预判型交易。" : temp >= 50 ? "优先做分歧后的回流和确认,不做无量冲高。" : "优先保守观察,只有最强确认才考虑出手。", ]; const watchItems = [ diff --git a/frontend/src/app/(auth)/recommendations/page.tsx b/frontend/src/app/(auth)/recommendations/page.tsx index c46660fe..377f3a81 100644 --- a/frontend/src/app/(auth)/recommendations/page.tsx +++ b/frontend/src/app/(auth)/recommendations/page.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { fetchAPI } from "@/lib/api"; import type { DayGroup, + LatestResult, OpsStatusResponse, PerformanceStats, RecommendationData, @@ -38,6 +39,7 @@ const SIGNAL_FILTERS = [ export default function RecommendationsPage() { const [dayGroups, setDayGroups] = useState([]); + const [latest, setLatest] = useState(null); const [expandedDays, setExpandedDays] = useState>(new Set()); const [historyFilter, setHistoryFilter] = useState("all"); const [focusTab, setFocusTab] = useState("actionable"); @@ -47,14 +49,16 @@ export default function RecommendationsPage() { const loadData = useCallback(async () => { try { - const [history, perf, iterationReport, ops] = await Promise.all([ + const [history, latestResult, perf, iterationReport, ops] = await Promise.all([ fetchAPI("/api/recommendations/history?days=14"), + fetchAPI("/api/recommendations/latest").catch(() => null), fetchAPI("/api/recommendations/performance").catch(() => null), fetchAPI("/api/market/strategy-iteration?limit=50").catch(() => null), fetchAPI("/api/market/ops-status").catch(() => null), ]); setDayGroups(history); + setLatest(latestResult); setPerformance(perf); setIteration(iterationReport); setOpsStatus(ops); @@ -93,10 +97,10 @@ export default function RecommendationsPage() { ); const latestDate = dayGroups[0]?.date ?? ""; - const latestRecommendations = useMemo( - () => (latestDate ? allRecommendations.filter((rec) => rec.groupDate === latestDate) : []), - [allRecommendations, latestDate] - ); + const latestRecommendations = useMemo(() => { + if (latest?.recommendations?.length) return latest.recommendations.map((rec) => ({ ...rec, groupDate: latestDate || "latest" })); + return latestDate ? allRecommendations.filter((rec) => rec.groupDate === latestDate) : []; + }, [allRecommendations, latest, latestDate]); const actionable = latestRecommendations.filter((rec) => rec.action_plan === "可操作" || rec.lifecycle_status === "actionable"); const watch = latestRecommendations.filter((rec) => rec.action_plan === "重点关注"); @@ -133,24 +137,41 @@ export default function RecommendationsPage() { const todayCount = applyHistoryFilter(dayGroups[0]?.recommendations ?? []).length; const totalCount = dayGroups.reduce((sum, group) => sum + applyHistoryFilter(group.recommendations).length, 0); - const focusSummary = buildFocusSummary({ actionable, watch, observe, tracking, closed, iteration, performance }); + const focusSummary = buildFocusSummary({ + strategyProfile: latest?.strategy_profile ?? null, + actionable, + watch, + observe, + tracking, + closed, + iteration, + performance, + }); return (
-

推荐池

+

今日决策池

- 先看今天围绕主线主题最值得处理的标的,再看观察和跟踪,历史放到下面。 + 先判断今天能不能做,再给极少数执行名单和观察名单,历史记录只用于复盘。

-
今日重点
+
今日结论

{focusSummary.headline}

{focusSummary.detail}

+ {latest?.strategy_profile ? ( +
+ + + +
+ ) : null} + {themeFocus.length ? (
{themeFocus.map(([theme, count]) => ( @@ -166,13 +187,13 @@ export default function RecommendationsPage() { ) : null}
- - + +
- + @@ -228,7 +249,7 @@ export default function RecommendationsPage() {
方法说明
- 推荐池不再试图在一个页面展示全部细节。首页只保留结论、触发和风险,更多分析进入个股详情。 + 这个页面现在承担“今天怎么做”的职责,不再展示一大堆弱候选。没有可操作标的时,空仓或只观察也是正常结果。
@@ -240,7 +261,7 @@ export default function RecommendationsPage() { {focusTabs.find((tab) => tab.key === focusTab)?.label ?? "焦点标的"}

- 默认只给少量高优先级标的,避免一上来就被大量卡片淹没。 + 只给今天真正要处理的少量标的,剩余候选不占主视图。

@@ -266,8 +287,8 @@ export default function RecommendationsPage() {
-

观察池

-

这些标的保留后台观察,不该和可操作标的一起抢注意力。

+

后台观察

+

只保留少量名字方便回看,不参与今天的主决策。

{observe.length} 只
@@ -396,6 +417,7 @@ export default function RecommendationsPage() { } function buildFocusSummary({ + strategyProfile, actionable, watch, observe, @@ -404,6 +426,7 @@ function buildFocusSummary({ iteration, performance, }: { + strategyProfile: LatestResult["strategy_profile"]; actionable: RecommendationData[]; watch: RecommendationData[]; observe: RecommendationData[]; @@ -412,36 +435,46 @@ function buildFocusSummary({ iteration: StrategyIterationReport | null; performance: PerformanceStats | null; }) { + const allowTrading = strategyProfile?.allow_trading ?? actionable.length > 0; const headline = - actionable.length > 0 - ? `今天先看 ${actionable.length} 只可操作标的` - : watch.length > 0 - ? `今天没有直接执行标的,重点看 ${watch.length} 只等待确认` - : "今天没有明确焦点,先保留观察"; + !allowTrading + ? "今天不主动出手,只保留观察名单" + : actionable.length > 0 + ? `今天只处理 ${actionable.length} 只可操作标的` + : watch.length > 0 + ? `今天没有直接执行标的,重点看 ${watch.length} 只等待确认` + : "今天没有明确优势机会,先观察"; const detail = - actionable.length > 0 - ? "推荐池首页只保留最接近执行的标的。真正的个股长分析进入详情页,不在这里堆满。" - : watch.length > 0 - ? "今天更偏等待确认,不适合在大量候选里反复横跳。" - : "当前更多是维护候选池和复盘闭环,不是积极出手阶段。"; + strategyProfile?.decision_note + ?? (!allowTrading + ? "系统判断今天更适合防守观察,不需要为了参与而强行找票。" + : actionable.length > 0 + ? "首页只保留最接近执行的标的,真正长分析进入个股详情。" + : watch.length > 0 + ? "今天偏等待确认,不适合在大量候选里反复横跳。" + : "当前更多是维护候选池和复盘闭环,不是积极出手阶段。"); const now = [ - actionable[0] + !allowTrading + ? "先看最强主线是否重新形成扩散和回流,再决定是否恢复进攻。" + : actionable[0] ? `先看 ${actionable[0].name}${actionable[0].trigger_condition ? ` 的触发条件是否成立` : " 是否进一步确认"}。` : watch[0] ? `盯住 ${watch[0].name} 是否从观察转成可操作。` : "只保留最强主线的少量候选,不主动扩池。", watch.length > 0 ? `${watch.length} 只重点关注标的只做跟踪,不提前下结论。` - : "没有重点关注时,不要强行从观察池里挑票。", + : allowTrading + ? "没有重点关注时,不要强行从观察池里挑票。" + : "没有确认信号前,不把观察股抬升为执行名单。", tracking.length > 0 ? `${tracking.length} 只跟踪中标的继续看兑现情况,避免只看新增不看结果。` : "如果没有跟踪样本,说明闭环还不够,要继续积累和复盘。", ]; const later = [ - observe.length > 0 ? `${observe.length} 只观察池标的不应占据首页主注意力。` : "没有必要把弱标的堆在默认视图。", + observe.length > 0 ? `${observe.length} 只后台观察标的不应占据首页主注意力。` : "没有必要把弱标的堆在默认视图。", closed.length > 0 ? `${closed.length} 只已结束样本主要用于复盘,不参与今日执行决策。` : "没有结束样本时,先积累更多闭环数据。", iteration?.summary || performance ? "策略迭代和胜率统计放在辅助区,不应该压过今天的执行结论。" @@ -509,3 +542,12 @@ function FreshnessCell({ label, value }: { label: string; value: string }) {
); } + +function SummaryChip({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2029b9b1..51a560b8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -228,7 +228,15 @@ export interface LatestResult { description?: string; buy_threshold?: number; min_score?: number; + max_position_pct?: number; + allow_trading?: boolean; + actionable_limit?: number; + watch_limit?: number; + target_focus_sectors?: number; + market_stance?: string; + decision_note?: string; notes?: string[]; + generated_by?: string; } | null; }