1
This commit is contained in:
parent
bcd478f701
commit
e1d4916615
@ -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 # 最小流通市值(亿)
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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({
|
||||
<HeroMetric label="可操作" value={actionableCount} tone="text-red-400" />
|
||||
<HeroMetric label="关注" value={watchCount} tone="text-amber-400" />
|
||||
<HeroMetric label="观察" value={observeCount} tone="text-text-secondary" />
|
||||
<HeroFact label="打法" value={board?.recommended_mode ?? "等待更新"} />
|
||||
<HeroFact label="仓位" value={board?.position_suggestion ?? "等待更新"} />
|
||||
<HeroFact label="打法" value={summary.modeLabel} />
|
||||
<HeroFact label="仓位" value={summary.positionLabel} />
|
||||
<HeroFact label="风险" value={board?.risk_level ?? "等待更新"} />
|
||||
</div>
|
||||
</div>
|
||||
@ -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 = [
|
||||
|
||||
@ -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<DayGroup[]>([]);
|
||||
const [latest, setLatest] = useState<LatestResult | null>(null);
|
||||
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
|
||||
const [historyFilter, setHistoryFilter] = useState<string>("all");
|
||||
const [focusTab, setFocusTab] = useState<FocusTab>("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<DayGroup[]>("/api/recommendations/history?days=14"),
|
||||
fetchAPI<LatestResult>("/api/recommendations/latest").catch(() => null),
|
||||
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
||||
fetchAPI<StrategyIterationReport>("/api/market/strategy-iteration?limit=50").catch(() => null),
|
||||
fetchAPI<OpsStatusResponse>("/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 (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{themeFocus.map(([theme, count]) => (
|
||||
@ -166,13 +187,13 @@ export default function RecommendationsPage() {
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<KeyList title="现在该看" items={focusSummary.now} />
|
||||
<KeyList title="暂时别看" items={focusSummary.later} />
|
||||
<KeyList title="执行原则" items={focusSummary.now} />
|
||||
<KeyList title="回避原则" items={focusSummary.later} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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={watch.length} tone="text-amber-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="text-[10px] uppercase tracking-wider text-text-muted font-semibold">方法说明</div>
|
||||
<div className="mt-2 text-xs leading-6 text-text-secondary">
|
||||
推荐池不再试图在一个页面展示全部细节。首页只保留结论、触发和风险,更多分析进入个股详情。
|
||||
这个页面现在承担“今天怎么做”的职责,不再展示一大堆弱候选。没有可操作标的时,空仓或只观察也是正常结果。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -240,7 +261,7 @@ export default function RecommendationsPage() {
|
||||
{focusTabs.find((tab) => tab.key === focusTab)?.label ?? "焦点标的"}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
默认只给少量高优先级标的,避免一上来就被大量卡片淹没。
|
||||
只给今天真正要处理的少量标的,剩余候选不占主视图。
|
||||
</p>
|
||||
</div>
|
||||
<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="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary">观察池</h2>
|
||||
<p className="mt-1 text-xs text-text-muted">这些标的保留后台观察,不该和可操作标的一起抢注意力。</p>
|
||||
<h2 className="text-sm font-semibold text-text-primary">后台观察</h2>
|
||||
<p className="mt-1 text-xs text-text-muted">只保留少量名字方便回看,不参与今天的主决策。</p>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">{observe.length} 只</span>
|
||||
</div>
|
||||
@ -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 }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user