This commit is contained in:
aaron 2026-04-28 11:56:40 +08:00
parent bcd478f701
commit e1d4916615
7 changed files with 342 additions and 145 deletions

View File

@ -29,11 +29,13 @@ class Settings(BaseSettings):
# 筛选参数 # 筛选参数
top_sector_count: int = 5 # 关注板块数量 top_sector_count: int = 5 # 关注板块数量
top_stock_count: int = 20 # 进入技术面筛选的个股数 top_stock_count: int = 6 # 最终推荐输出上限
candidate_pool_limit: int = 120 # 多路召回后的候选池上限 candidate_pool_limit: int = 90 # 多路召回后的候选池上限
llm_prefilter_limit: int = 36 # LLM 初筛保留数量 llm_prefilter_limit: int = 24 # LLM 初筛保留数量
llm_prefilter_max_concurrent: int = 6 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 # 最小换手率 % min_turnover_rate: float = 2.0 # 最小换手率 %
max_turnover_rate: float = 30.0 # 最大换手率 % max_turnover_rate: float = 30.0 # 最大换手率 %
min_circ_mv: float = 50.0 # 最小流通市值(亿) min_circ_mv: float = 50.0 # 最小流通市值(亿)

View File

@ -12,6 +12,7 @@ from functools import partial
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.engine.screener import run_screening from app.engine.screener import run_screening
from app.data.models import Recommendation, MarketTemperature, SectorInfo 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.database import get_db
from app.db import tables from app.db import tables
@ -928,10 +929,11 @@ async def _load_today_from_db() -> dict:
"hot_sectors": [], "hot_sectors": [],
"capital_filtered": [], "capital_filtered": [],
"recommendations": recommendations, "recommendations": recommendations,
"strategy_profile": { "strategy_profile": (
"strategy_id": recommendations[0].strategy if recommendations else "trend_breakout", get_strategy_profile_by_id(recommendations[0].strategy).model_dump()
"name": "当前推荐策略", if recommendations
} if recommendations else None, else None
),
} }
except Exception as e: except Exception as e:
logger.error(f"从数据库加载推荐失败: {e}") logger.error(f"从数据库加载推荐失败: {e}")

View File

@ -39,7 +39,7 @@ from app.analysis.intraday import (
from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Recommendation from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Recommendation
from app.config import settings, should_prefer_realtime_today from app.config import settings, should_prefer_realtime_today
from app.data.tushare_client import tushare_client 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__) 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 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}) ===") logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===")
for r in recommendations[:5]: for r in recommendations[:5]:
signal_map = {"breakout": "突破型", "breakout_confirm": "确认型", "pullback": "回踩型", "launch": "启动型", "reversal": "反转型"} signal_map = {"breakout": "突破型", "breakout_confirm": "确认型", "pullback": "回踩型", "launch": "启动型", "reversal": "反转型"}
@ -452,6 +459,114 @@ def _route_recall_weight(route: str, item: dict) -> float:
return 0 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( async def _build_recommendations(
candidates: list[dict], candidates: list[dict],
market_temp: MarketTemperature, market_temp: MarketTemperature,
@ -1221,15 +1336,18 @@ def _build_trade_plan(
"reversal": "放量反转", "reversal": "放量反转",
}.get(signal_type, "技术信号") }.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 = "观察" action_plan = "观察"
lifecycle_status = "candidate" 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 = "可操作" action_plan = "可操作"
lifecycle_status = "actionable" lifecycle_status = "actionable"
elif score >= 65: elif score >= 72 and market_temp.temperature >= 48 and sector_stage in ("early", "mid"):
action_plan = "重点关注" action_plan = "重点关注"
lifecycle_status = "candidate" lifecycle_status = "candidate"
elif score >= 64 and sector_stage != "end":
action_plan = "观察"
lifecycle_status = "candidate"
else: else:
action_plan = "观察" action_plan = "观察"
lifecycle_status = "candidate" lifecycle_status = "candidate"

View File

@ -24,10 +24,97 @@ class StrategyProfile(BaseModel):
min_score: float min_score: float
buy_threshold: float buy_threshold: float
max_position_pct: 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] = [] notes: list[str] = []
generated_by: str = "rules" 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( async def select_strategy_profile(
market_temp: MarketTemperature | None, market_temp: MarketTemperature | None,
hot_sectors: list[SectorInfo], 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")) late_count = sum(1 for s in hot_sectors[:5] if s.stage in ("late", "end"))
if temp >= 65 and early_count >= 1: if temp >= 65 and early_count >= 1:
return StrategyProfile( return get_strategy_profile_by_id("breakout_attack")
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=["优先做主线早中期板块", "放量突破优先于回踩低吸"],
)
if temp >= 45 and late_count < 2: if temp >= 45 and late_count < 2:
return StrategyProfile( return get_strategy_profile_by_id("pullback_rotation")
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=["降低追高仓位", "更看重位置安全和回踩承接"],
)
if temp >= 30: if temp >= 30:
return StrategyProfile( return get_strategy_profile_by_id("launch_probe")
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 StrategyProfile( return get_strategy_profile_by_id("defensive_watch")
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=["原则上只保留观察池", "等待市场温度修复后再转入主动进攻"],
)
async def _select_llm_profile( async def _select_llm_profile(
@ -157,48 +204,7 @@ async def _select_llm_profile(
return None return None
selected = _select_rule_profile(market_temp, hot_sectors, intraday) selected = _select_rule_profile(market_temp, hot_sectors, intraday)
if selected.strategy_id != strategy_id: if selected.strategy_id != strategy_id:
selected = { selected = get_strategy_profile_by_id(strategy_id)
"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]
delta = int(data.get("buy_threshold_delta", 0)) delta = int(data.get("buy_threshold_delta", 0))
delta = max(-3, min(3, delta)) delta = max(-3, min(3, delta))

View File

@ -171,13 +171,13 @@ export default function DashboardPage() {
const observe = recommendations.filter((rec) => !["可操作", "重点关注"].includes(rec.action_plan ?? "")); const observe = recommendations.filter((rec) => !["可操作", "重点关注"].includes(rec.action_plan ?? ""));
const marketSummary = useMemo( const marketSummary = useMemo(
() => buildMarketSummary(marketTemperature, strategyBoard, scanStatus, actionable.length, watch.length), () => buildMarketSummary(data?.strategy_profile ?? null, marketTemperature, strategyBoard, scanStatus, actionable.length, watch.length),
[actionable.length, marketTemperature, scanStatus, strategyBoard, watch.length] [actionable.length, data?.strategy_profile, marketTemperature, scanStatus, strategyBoard, watch.length]
); );
const todayActions = useMemo( const todayActions = useMemo(
() => buildActionGuides(strategyBoard, marketTemperature, actionable, watch, observe, sectors), () => buildActionGuides(data?.strategy_profile ?? null, strategyBoard, marketTemperature, actionable, watch, observe, sectors),
[actionable, marketTemperature, observe, sectors, strategyBoard, watch] [actionable, data?.strategy_profile, marketTemperature, observe, sectors, strategyBoard, watch]
); );
const focusQueue = actionable.length ? actionable : watch.length ? watch : recommendations; const focusQueue = actionable.length ? actionable : watch.length ? watch : recommendations;
@ -310,8 +310,8 @@ function DecisionHero({
<HeroMetric label="可操作" value={actionableCount} tone="text-red-400" /> <HeroMetric label="可操作" value={actionableCount} tone="text-red-400" />
<HeroMetric label="关注" value={watchCount} tone="text-amber-400" /> <HeroMetric label="关注" value={watchCount} tone="text-amber-400" />
<HeroMetric label="观察" value={observeCount} tone="text-text-secondary" /> <HeroMetric label="观察" value={observeCount} tone="text-text-secondary" />
<HeroFact label="打法" value={board?.recommended_mode ?? "等待更新"} /> <HeroFact label="打法" value={summary.modeLabel} />
<HeroFact label="仓位" value={board?.position_suggestion ?? "等待更新"} /> <HeroFact label="仓位" value={summary.positionLabel} />
<HeroFact label="风险" value={board?.risk_level ?? "等待更新"} /> <HeroFact label="风险" value={board?.risk_level ?? "等待更新"} />
</div> </div>
</div> </div>
@ -630,6 +630,7 @@ function FreshnessPill({ label, value }: { label: string; value: string }) {
} }
function buildMarketSummary( function buildMarketSummary(
strategyProfile: LatestResult["strategy_profile"],
marketTemperature: MarketTemperatureData | null, marketTemperature: MarketTemperatureData | null,
board: StrategyBoard | null, board: StrategyBoard | null,
scanStatus: ScanStatus | null, scanStatus: ScanStatus | null,
@ -637,35 +638,50 @@ function buildMarketSummary(
watchCount: number watchCount: number
) { ) {
const temp = marketTemperature?.temperature ?? board?.metrics.temperature ?? 0; const temp = marketTemperature?.temperature ?? board?.metrics.temperature ?? 0;
const headline = const allowTrading = strategyProfile?.allow_trading ?? actionableCount > 0;
board?.market_regime ?? const headline = !allowTrading
(temp >= 70 ? "市场偏强,可以围绕主线进攻" : ? "今天不主动出手,先防守观察"
temp >= 50 ? "市场可做,但只做确认机会" : : board?.market_regime ??
temp >= 30 ? "市场分化,轻仓试错" : (temp >= 70 ? "市场偏强,可以围绕主线进攻" :
"市场偏弱,以观察为主"); temp >= 50 ? "市场可做,但只做确认机会" :
temp >= 30 ? "市场分化,轻仓试错" :
"市场偏弱,以观察为主");
const detail = const detail =
strategyProfile?.decision_note ??
board?.summary ?? board?.summary ??
(scanStatus?.is_trading (scanStatus?.is_trading
? "当前使用盘中实时数据判断市场,重点在节奏、仓位和主线强弱,而不是静态分数。" ? "当前使用盘中实时数据判断市场,重点在节奏、仓位和主线强弱,而不是静态分数。"
: "当前以收盘后数据为主,适合复盘主线、更新候选池和准备下一交易日。"); : "当前以收盘后数据为主,适合复盘主线、更新候选池和准备下一交易日。");
const canDo = [ const canDo = [
actionableCount > 0 ? `优先只看 ${actionableCount} 只可操作标的,避免在杂波里分散注意力。` : "没有明确可操作标的时,只保留观察,不主动开新仓。", !allowTrading
? "今天只做观察和等待,不把普通异动抬升为买入机会。"
: actionableCount > 0
? `优先只看 ${actionableCount} 只可操作标的,避免在杂波里分散注意力。`
: "没有明确可操作标的时,只保留观察,不主动开新仓。",
watchCount > 0 ? `重点关注 ${watchCount} 只等待确认的标的,等放量、回流或分歧转一致。` : "把注意力放在最强板块前排,而不是平均分配给所有候选。", watchCount > 0 ? `重点关注 ${watchCount} 只等待确认的标的,等放量、回流或分歧转一致。` : "把注意力放在最强板块前排,而不是平均分配给所有候选。",
board?.position_suggestion ?? (temp >= 50 ? "仓位可以试错,但不要脱离主线。" : "控制仓位,等待更强确认。"), board?.position_suggestion ?? (strategyProfile?.max_position_pct ? `总仓位上限先按 ${strategyProfile.max_position_pct}% 控制。` : temp >= 50 ? "仓位可以试错,但不要脱离主线。" : "控制仓位,等待更强确认。"),
]; ];
const cannotDo = [ const cannotDo = [
board?.avoid_rules?.[0] ?? "不要追后排、跟风和没有板块支撑的个股。", board?.avoid_rules?.[0] ?? "不要追后排、跟风和没有板块支撑的个股。",
board?.avoid_rules?.[1] ?? (temp < 50 ? "不要因为个别异动就误判成全面回暖。" : "不要把盘中脉冲当成全天主线。"), 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( function buildActionGuides(
strategyProfile: LatestResult["strategy_profile"],
board: StrategyBoard | null, board: StrategyBoard | null,
marketTemperature: MarketTemperatureData | null, marketTemperature: MarketTemperatureData | null,
actionable: RecommendationData[], actionable: RecommendationData[],
@ -677,9 +693,12 @@ function buildActionGuides(
const backupSectors = sectors.slice(0, 2).map((sector) => sector.sector_name); const backupSectors = sectors.slice(0, 2).map((sector) => sector.sector_name);
const focusSectors = topSectors.length ? topSectors : backupSectors; const focusSectors = topSectors.length ? topSectors : backupSectors;
const temp = marketTemperature?.temperature ?? board?.metrics.temperature ?? 0; const temp = marketTemperature?.temperature ?? board?.metrics.temperature ?? 0;
const allowTrading = strategyProfile?.allow_trading ?? actionable.length > 0;
const priority = [ const priority = [
actionable[0] !allowTrading
? "先等市场重新给出清晰主线和承接,不主动寻找执行票。"
: actionable[0]
? `先盯 ${actionable[0].name}${actionable[0].trigger_condition ? `,触发条件是 ${actionable[0].trigger_condition}` : " 的确认信号"}` ? `先盯 ${actionable[0].name}${actionable[0].trigger_condition ? `,触发条件是 ${actionable[0].trigger_condition}` : " 的确认信号"}`
: focusSectors[0] : focusSectors[0]
? `主看 ${focusSectors[0]} 前排是否继续强化,再决定是否参与。` ? `主看 ${focusSectors[0]} 前排是否继续强化,再决定是否参与。`
@ -687,7 +706,7 @@ function buildActionGuides(
focusSectors[1] focusSectors[1]
? `${focusSectors.slice(0, 2).join("、")} 作为主线池,不要同时追太多方向。` ? `${focusSectors.slice(0, 2).join("、")} 作为主线池,不要同时追太多方向。`
: "今天只围绕一条最强主线做决策,避免来回切换。", : "今天只围绕一条最强主线做决策,避免来回切换。",
temp >= 50 ? "优先做分歧后的回流和确认,不做无量冲高。" : "优先保守观察,只有最强确认才考虑出手。", !allowTrading ? "今天优先保守观察,不做预判型交易。" : temp >= 50 ? "优先做分歧后的回流和确认,不做无量冲高。" : "优先保守观察,只有最强确认才考虑出手。",
]; ];
const watchItems = [ const watchItems = [

View File

@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchAPI } from "@/lib/api"; import { fetchAPI } from "@/lib/api";
import type { import type {
DayGroup, DayGroup,
LatestResult,
OpsStatusResponse, OpsStatusResponse,
PerformanceStats, PerformanceStats,
RecommendationData, RecommendationData,
@ -38,6 +39,7 @@ const SIGNAL_FILTERS = [
export default function RecommendationsPage() { export default function RecommendationsPage() {
const [dayGroups, setDayGroups] = useState<DayGroup[]>([]); const [dayGroups, setDayGroups] = useState<DayGroup[]>([]);
const [latest, setLatest] = useState<LatestResult | null>(null);
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set()); const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
const [historyFilter, setHistoryFilter] = useState<string>("all"); const [historyFilter, setHistoryFilter] = useState<string>("all");
const [focusTab, setFocusTab] = useState<FocusTab>("actionable"); const [focusTab, setFocusTab] = useState<FocusTab>("actionable");
@ -47,14 +49,16 @@ export default function RecommendationsPage() {
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
const [history, perf, iterationReport, ops] = await Promise.all([ const [history, latestResult, perf, iterationReport, ops] = await Promise.all([
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"), fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
fetchAPI<LatestResult>("/api/recommendations/latest").catch(() => null),
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null), fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
fetchAPI<StrategyIterationReport>("/api/market/strategy-iteration?limit=50").catch(() => null), fetchAPI<StrategyIterationReport>("/api/market/strategy-iteration?limit=50").catch(() => null),
fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null), fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null),
]); ]);
setDayGroups(history); setDayGroups(history);
setLatest(latestResult);
setPerformance(perf); setPerformance(perf);
setIteration(iterationReport); setIteration(iterationReport);
setOpsStatus(ops); setOpsStatus(ops);
@ -93,10 +97,10 @@ export default function RecommendationsPage() {
); );
const latestDate = dayGroups[0]?.date ?? ""; const latestDate = dayGroups[0]?.date ?? "";
const latestRecommendations = useMemo( const latestRecommendations = useMemo(() => {
() => (latestDate ? allRecommendations.filter((rec) => rec.groupDate === latestDate) : []), if (latest?.recommendations?.length) return latest.recommendations.map((rec) => ({ ...rec, groupDate: latestDate || "latest" }));
[allRecommendations, latestDate] return latestDate ? allRecommendations.filter((rec) => rec.groupDate === latestDate) : [];
); }, [allRecommendations, latest, latestDate]);
const actionable = latestRecommendations.filter((rec) => rec.action_plan === "可操作" || rec.lifecycle_status === "actionable"); const actionable = latestRecommendations.filter((rec) => rec.action_plan === "可操作" || rec.lifecycle_status === "actionable");
const watch = latestRecommendations.filter((rec) => rec.action_plan === "重点关注"); const watch = latestRecommendations.filter((rec) => rec.action_plan === "重点关注");
@ -133,24 +137,41 @@ export default function RecommendationsPage() {
const todayCount = applyHistoryFilter(dayGroups[0]?.recommendations ?? []).length; const todayCount = applyHistoryFilter(dayGroups[0]?.recommendations ?? []).length;
const totalCount = dayGroups.reduce((sum, group) => sum + applyHistoryFilter(group.recommendations).length, 0); 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 ( return (
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5"> <div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
<div className="animate-fade-in-up"> <div className="animate-fade-in-up">
<h1 className="text-base sm:text-lg font-bold tracking-tight"></h1> <h1 className="text-base sm:text-lg font-bold tracking-tight"></h1>
<p className="mt-1 text-xs text-text-muted"> <p className="mt-1 text-xs text-text-muted">
线
</p> </p>
</div> </div>
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up"> <div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4"> <div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4">
<div> <div>
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold"></div> <div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold"></div>
<h2 className="mt-2 text-xl font-bold tracking-tight text-text-primary">{focusSummary.headline}</h2> <h2 className="mt-2 text-xl font-bold tracking-tight text-text-primary">{focusSummary.headline}</h2>
<p className="mt-2 text-sm leading-6 text-text-secondary">{focusSummary.detail}</p> <p className="mt-2 text-sm leading-6 text-text-secondary">{focusSummary.detail}</p>
{latest?.strategy_profile ? (
<div className="mt-3 flex flex-wrap gap-2">
<SummaryChip label="策略" value={latest.strategy_profile.name} />
<SummaryChip label="立场" value={latest.strategy_profile.market_stance || (latest.strategy_profile.allow_trading ? "谨慎进攻" : "防守观察")} />
<SummaryChip label="仓位上限" value={`${latest.strategy_profile.max_position_pct ?? 0}%`} />
</div>
) : null}
{themeFocus.length ? ( {themeFocus.length ? (
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
{themeFocus.map(([theme, count]) => ( {themeFocus.map(([theme, count]) => (
@ -166,13 +187,13 @@ export default function RecommendationsPage() {
) : null} ) : null}
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
<KeyList title="现在该看" items={focusSummary.now} /> <KeyList title="执行原则" items={focusSummary.now} />
<KeyList title="暂时别看" items={focusSummary.later} /> <KeyList title="回避原则" items={focusSummary.later} />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2 self-start"> <div className="grid grid-cols-2 gap-2 self-start">
<SummaryMetric label="今日入池" value={latestRecommendations.length} tone="text-text-primary" /> <SummaryMetric label="今日保留" value={latestRecommendations.length} tone="text-text-primary" />
<SummaryMetric label="可操作" value={actionable.length} tone="text-red-400" /> <SummaryMetric label="可操作" value={actionable.length} tone="text-red-400" />
<SummaryMetric label="重点关注" value={watch.length} tone="text-amber-400" /> <SummaryMetric label="重点关注" value={watch.length} tone="text-amber-400" />
<SummaryMetric label="跟踪中" value={tracking.length} tone="text-cyan-400" /> <SummaryMetric label="跟踪中" value={tracking.length} tone="text-cyan-400" />
@ -228,7 +249,7 @@ export default function RecommendationsPage() {
<div className="mt-4 rounded-2xl border border-border-subtle bg-surface-1/70 p-3"> <div className="mt-4 rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
<div className="text-[10px] uppercase tracking-wider text-text-muted font-semibold"></div> <div className="text-[10px] uppercase tracking-wider text-text-muted font-semibold"></div>
<div className="mt-2 text-xs leading-6 text-text-secondary"> <div className="mt-2 text-xs leading-6 text-text-secondary">
</div> </div>
</div> </div>
</div> </div>
@ -240,7 +261,7 @@ export default function RecommendationsPage() {
{focusTabs.find((tab) => tab.key === focusTab)?.label ?? "焦点标的"} {focusTabs.find((tab) => tab.key === focusTab)?.label ?? "焦点标的"}
</h2> </h2>
<p className="mt-1 text-xs text-text-muted"> <p className="mt-1 text-xs text-text-muted">
</p> </p>
</div> </div>
<a href="/strategy" className="text-xs text-text-muted transition-colors hover:text-amber-400"> <a href="/strategy" className="text-xs text-text-muted transition-colors hover:text-amber-400">
@ -266,8 +287,8 @@ export default function RecommendationsPage() {
<div className="glass-card-static p-4 md:p-5"> <div className="glass-card-static p-4 md:p-5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<h2 className="text-sm font-semibold text-text-primary"></h2> <h2 className="text-sm font-semibold text-text-primary"></h2>
<p className="mt-1 text-xs text-text-muted"></p> <p className="mt-1 text-xs text-text-muted">便</p>
</div> </div>
<span className="text-xs text-text-muted">{observe.length} </span> <span className="text-xs text-text-muted">{observe.length} </span>
</div> </div>
@ -396,6 +417,7 @@ export default function RecommendationsPage() {
} }
function buildFocusSummary({ function buildFocusSummary({
strategyProfile,
actionable, actionable,
watch, watch,
observe, observe,
@ -404,6 +426,7 @@ function buildFocusSummary({
iteration, iteration,
performance, performance,
}: { }: {
strategyProfile: LatestResult["strategy_profile"];
actionable: RecommendationData[]; actionable: RecommendationData[];
watch: RecommendationData[]; watch: RecommendationData[];
observe: RecommendationData[]; observe: RecommendationData[];
@ -412,36 +435,46 @@ function buildFocusSummary({
iteration: StrategyIterationReport | null; iteration: StrategyIterationReport | null;
performance: PerformanceStats | null; performance: PerformanceStats | null;
}) { }) {
const allowTrading = strategyProfile?.allow_trading ?? actionable.length > 0;
const headline = const headline =
actionable.length > 0 !allowTrading
? `今天先看 ${actionable.length} 只可操作标的` ? "今天不主动出手,只保留观察名单"
: watch.length > 0 : actionable.length > 0
? `今天没有直接执行标的,重点看 ${watch.length} 只等待确认` ? `今天只处理 ${actionable.length} 只可操作标的`
: "今天没有明确焦点,先保留观察"; : watch.length > 0
? `今天没有直接执行标的,重点看 ${watch.length} 只等待确认`
: "今天没有明确优势机会,先观察";
const detail = const detail =
actionable.length > 0 strategyProfile?.decision_note
? "推荐池首页只保留最接近执行的标的。真正的个股长分析进入详情页,不在这里堆满。" ?? (!allowTrading
: watch.length > 0 ? "系统判断今天更适合防守观察,不需要为了参与而强行找票。"
? "今天更偏等待确认,不适合在大量候选里反复横跳。" : actionable.length > 0
: "当前更多是维护候选池和复盘闭环,不是积极出手阶段。"; ? "首页只保留最接近执行的标的,真正长分析进入个股详情。"
: watch.length > 0
? "今天偏等待确认,不适合在大量候选里反复横跳。"
: "当前更多是维护候选池和复盘闭环,不是积极出手阶段。");
const now = [ const now = [
actionable[0] !allowTrading
? "先看最强主线是否重新形成扩散和回流,再决定是否恢复进攻。"
: actionable[0]
? `先看 ${actionable[0].name}${actionable[0].trigger_condition ? ` 的触发条件是否成立` : " 是否进一步确认"}` ? `先看 ${actionable[0].name}${actionable[0].trigger_condition ? ` 的触发条件是否成立` : " 是否进一步确认"}`
: watch[0] : watch[0]
? `盯住 ${watch[0].name} 是否从观察转成可操作。` ? `盯住 ${watch[0].name} 是否从观察转成可操作。`
: "只保留最强主线的少量候选,不主动扩池。", : "只保留最强主线的少量候选,不主动扩池。",
watch.length > 0 watch.length > 0
? `${watch.length} 只重点关注标的只做跟踪,不提前下结论。` ? `${watch.length} 只重点关注标的只做跟踪,不提前下结论。`
: "没有重点关注时,不要强行从观察池里挑票。", : allowTrading
? "没有重点关注时,不要强行从观察池里挑票。"
: "没有确认信号前,不把观察股抬升为执行名单。",
tracking.length > 0 tracking.length > 0
? `${tracking.length} 只跟踪中标的继续看兑现情况,避免只看新增不看结果。` ? `${tracking.length} 只跟踪中标的继续看兑现情况,避免只看新增不看结果。`
: "如果没有跟踪样本,说明闭环还不够,要继续积累和复盘。", : "如果没有跟踪样本,说明闭环还不够,要继续积累和复盘。",
]; ];
const later = [ const later = [
observe.length > 0 ? `${observe.length}观察标的不应占据首页主注意力。` : "没有必要把弱标的堆在默认视图。", observe.length > 0 ? `${observe.length}后台观察标的不应占据首页主注意力。` : "没有必要把弱标的堆在默认视图。",
closed.length > 0 ? `${closed.length} 只已结束样本主要用于复盘,不参与今日执行决策。` : "没有结束样本时,先积累更多闭环数据。", closed.length > 0 ? `${closed.length} 只已结束样本主要用于复盘,不参与今日执行决策。` : "没有结束样本时,先积累更多闭环数据。",
iteration?.summary || performance iteration?.summary || performance
? "策略迭代和胜率统计放在辅助区,不应该压过今天的执行结论。" ? "策略迭代和胜率统计放在辅助区,不应该压过今天的执行结论。"
@ -509,3 +542,12 @@ function FreshnessCell({ label, value }: { label: string; value: string }) {
</div> </div>
); );
} }
function SummaryChip({ label, value }: { label: string; value: string }) {
return (
<span className="inline-flex items-center gap-2 rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-1.5 text-xs text-text-secondary">
<span className="text-text-muted">{label}</span>
<span className="font-medium text-text-primary">{value}</span>
</span>
);
}

View File

@ -228,7 +228,15 @@ export interface LatestResult {
description?: string; description?: string;
buy_threshold?: number; buy_threshold?: number;
min_score?: 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[]; notes?: string[];
generated_by?: string;
} | null; } | null;
} }