"""主题驱动的 A 股中短线筛选器。 三阶段管道: Step 1: 主线定位 — 把实时板块/快照板块归一成系统 MarketTheme Step 2: 主题内选股 — 从主线主题成分、领涨股和实时异动中召回候选 Step 3: 规则定价 — 催化 + 主题资金 + 个股资金 + 情绪角色 + 入场节奏 评分公式:市场热点/新闻催化 + 主线资金 + 个股资金 + 情绪地位 + 时机。 技术指标只作为入场节奏与风控参考,不替代热点与资金主线。 风险乘数:惩罚取最大而非叠加(防过度惩罚),奖励可叠加。 数据源: - 盘中模式:Tushare 日线 + 腾讯实时行情 + 东方财富5分钟K线 - 盘后模式:Tushare 当日完整数据 止损止盈:基于市场结构(阻力位/支撑MA/近期低点),而非固定百分比。 """ import asyncio import logging import pandas as pd from app.analysis.market_temp import build_realtime_market_temperature, calculate_market_temperature from app.analysis.sector_scanner import scan_hot_sectors from app.analysis.sector_realtime import get_today_realtime_sector_board from app.analysis.sector_alignment import build_hot_theme_membership, find_hot_theme_match from app.analysis.theme_mapper import merge_sectors_to_themes from app.analysis.trend_scanner import scan_trend_breakout from app.analysis.signals import generate_signals from app.analysis.intraday import ( intraday_active_market_recall, intraday_market_temperature, intraday_filter_stocks, intraday_sector_scan, ) 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 StrategyProfile, select_strategy_profile from app.catalyst.service import build_theme_catalyst_scores from app.db.scan_logger import log_scan_stage from app.db.research_logger import save_research_observations logger = logging.getLogger(__name__) def _is_main_theme_recommendation(rec: Recommendation) -> bool: tags = set(rec.recall_tags or []) return bool(tags & {"hot_theme_core", "theme_leader", "top_theme_member", "sector_recall"}) async def run_screening(trade_date: str = None, scan_session: str = "manual") -> dict: """执行趋势突破筛选流程 返回: { "market_temp": MarketTemperature, "hot_sectors": [SectorInfo], "recommendations": [Recommendation], "scan_mode": "intraday" | "post_market", } """ latest_trade_date = tushare_client.get_latest_trade_date() intraday = should_prefer_realtime_today(latest_trade_date) scan_mode = "realtime_today" if intraday else "post_market" logger.info(f"=== 筛选模式: {'今日实时' if intraday else '历史收盘'} ===") # ── 市场温度 ── logger.info("=== 市场温度计 ===") market_temp = calculate_market_temperature(trade_date) if intraday: market_temp, realtime_used = await build_realtime_market_temperature(market_temp) if realtime_used: logger.info(f"实时市场温度(统一广度口径): {market_temp.temperature}") else: market_temp = await intraday_market_temperature(market_temp) logger.info(f"盘中市场温度(兼容回退): {market_temp.temperature}") else: logger.info(f"市场温度: {market_temp.temperature}") market_temp_score = market_temp.temperature await log_scan_stage( scan_session=scan_session, scan_mode=scan_mode, stage="market_temperature", stage_label="市场温度", input_count=(market_temp.up_count or 0) + (market_temp.down_count or 0), output_count=1, filtered_count=0, summary=f"市场温度 {market_temp.temperature:.1f},上涨{market_temp.up_count or 0}家,下跌{market_temp.down_count or 0}家", detail={ "temperature": market_temp.temperature, "up_count": market_temp.up_count, "down_count": market_temp.down_count, "limit_up_count": market_temp.limit_up_count, "limit_down_count": market_temp.limit_down_count, "intraday": intraday, }, ) # ── Step 1: 主线主题定位 ── logger.info("=== Step 1: 主线主题定位 ===") all_themes = await get_today_realtime_sector_board(limit=30) if intraday else [] if not all_themes: all_themes = merge_sectors_to_themes(scan_hot_sectors(trade_date), limit=30) # 前置过滤:只保留有资金或实时强度支撑、且非尾声的主题 hot_sectors = [ s for s in all_themes if (s.capital_inflow > 0 or s.is_realtime) and s.stage not in ("end",) ][:settings.top_sector_count] if not hot_sectors: logger.info("无合格主线主题(需要资金/实时强度+非尾声),回退到全部主题") hot_sectors = all_themes[:settings.top_sector_count] hot_sectors = await _apply_catalyst_scores(hot_sectors) await log_scan_stage( scan_session=scan_session, scan_mode=scan_mode, stage="theme_selection", stage_label="主线主题", input_count=len(all_themes), output_count=len(hot_sectors), summary=f"从 {len(all_themes)} 个主题中保留 {len(hot_sectors)} 条主线", detail={ "themes": [ { "name": s.sector_name, "heat_score": s.heat_score, "pct_change": s.realtime_pct_change if s.realtime_pct_change is not None else s.pct_change, "capital_inflow": s.capital_inflow, "limit_up_count": s.limit_up_count, "stage": s.stage, "catalyst_score": getattr(s, "catalyst_score", 0), "catalyst_count": getattr(s, "catalyst_count", 0), } for s in hot_sectors[:10] ], }, ) for s in hot_sectors: logger.info(f" 目标主题: {s.sector_name} 涨幅{s.pct_change}% 资金{s.capital_inflow:.0f}万 " f"涨停{s.limit_up_count} 阶段={s.stage}") # 如果主题来自 Tushare 快照,盘中用实时行情更新后再次归一到主题。 if intraday and hot_sectors and not hot_sectors[0].is_realtime: hot_sectors = merge_sectors_to_themes(await intraday_sector_scan(hot_sectors), limit=settings.top_sector_count) strategy_profile = await select_strategy_profile(market_temp, hot_sectors, intraday) logger.info( f"=== 今日策略: {strategy_profile.name} ({strategy_profile.strategy_id}) " f"threshold={strategy_profile.buy_threshold} min_score={strategy_profile.min_score} ===" ) await log_scan_stage( scan_session=scan_session, scan_mode=scan_mode, stage="strategy_profile", stage_label="策略参数", input_count=len(hot_sectors), output_count=1, filtered_count=0, summary=f"{strategy_profile.name}: 买入线 {strategy_profile.buy_threshold},保留线 {strategy_profile.min_score}", detail=strategy_profile.model_dump(), ) # ── Step 2: 多路召回构建候选池 ── logger.info("=== Step 2: 多路召回候选池 ===") candidate_metrics: dict = {} candidates = await _build_candidate_pool( hot_sectors=hot_sectors, trade_date=trade_date, intraday=intraday, market_temp=market_temp, metrics=candidate_metrics, ) await log_scan_stage( scan_session=scan_session, scan_mode=scan_mode, stage="candidate_recall", stage_label="候选召回", input_count=len(hot_sectors), output_count=len(candidates), filtered_count=max(int(candidate_metrics.get("merged_count", 0) or 0) - len(candidates), 0), summary=f"多路召回合并后进入规则评分 {len(candidates)} 只", detail=candidate_metrics, ) if not candidates: logger.info("=== 筛选完成: 0 只股票 ===") await log_scan_stage( scan_session=scan_session, scan_mode=scan_mode, stage="final_filter", stage_label="最终作战池", input_count=0, output_count=0, filtered_count=0, status="empty", summary="候选池为空,本轮没有形成推荐", ) return { "market_temp": market_temp, "hot_sectors": hot_sectors, "recommendations": [], "scan_mode": scan_mode, } # ── Step 3 之前:注入腾讯实时价格(防止 Tushare 日线数据过时) ── if candidates: quote_requested = len([c for c in candidates if "ts_code" in c]) quote_updated = 0 quote_error = "" try: from app.data.tencent_client import get_realtime_quotes_batch codes = [c["ts_code"] for c in candidates if "ts_code" in c] quotes = await get_realtime_quotes_batch(codes) for c in candidates: q = quotes.get(c["ts_code"]) if q and q.price > 0: c["price"] = q.price quote_updated += 1 except Exception as e: quote_error = str(e) logger.warning(f"注入实时价格失败,使用 Tushare 收盘价: {e}") await log_scan_stage( scan_session=scan_session, scan_mode=scan_mode, stage="realtime_quote", stage_label="实时行情校准", input_count=quote_requested, output_count=quote_updated, status="warning" if quote_error else "ok", summary=f"实时行情更新 {quote_updated}/{quote_requested} 只", detail={"requested": quote_requested, "updated": quote_updated, "error": quote_error}, ) # ── Step 3: 规则评分与交易计划 ── logger.info("=== Step 3: 规则评分与交易计划 ===") scoring_metrics: dict = {} research_observations: list[dict] = [] recommendations = await _build_recommendations( candidates, market_temp, hot_sectors, market_temp_score, intraday, strategy_profile, metrics=scoring_metrics, research_observations=research_observations, scan_session=scan_session, scan_mode=scan_mode, ) await log_scan_stage( scan_session=scan_session, scan_mode=scan_mode, stage="rule_scoring", stage_label="规则评分", input_count=len(candidates), output_count=len(recommendations), summary=f"完成 {scoring_metrics.get('analyzed_count', len(candidates))} 只规则评分,生成 {len(recommendations)} 个交易计划", detail=scoring_metrics, ) before_final_filter = len(recommendations) final_filter_reasons = _build_final_filter_reasons(recommendations, strategy_profile) recommendations = [ r for r in recommendations if _is_main_theme_recommendation(r) and r.score >= strategy_profile.min_score ] after_theme_filter = len(recommendations) recommendations = _finalize_battle_plan( recommendations=recommendations, hot_sectors=hot_sectors, market_temp=market_temp, strategy_profile=strategy_profile, ) action_counts = {"可操作": 0, "重点关注": 0, "观察": 0} for rec in recommendations: action_counts[rec.action_plan] = action_counts.get(rec.action_plan, 0) + 1 final_codes = {rec.ts_code for rec in recommendations} _apply_final_research_outcomes( observations=research_observations, final_codes=final_codes, final_filter_reasons=final_filter_reasons, min_score=strategy_profile.min_score, ) await save_research_observations(research_observations) await log_scan_stage( scan_session=scan_session, scan_mode=scan_mode, stage="final_filter", stage_label="最终作战池", input_count=before_final_filter, output_count=len(recommendations), filtered_count=max(before_final_filter - len(recommendations), 0), status="empty" if len(recommendations) == 0 else "ok", summary=f"主线与分数过滤后保留 {after_theme_filter} 只,最终作战池 {len(recommendations)} 只", detail={ "before_final_filter": before_final_filter, "after_theme_score_filter": after_theme_filter, "final_count": len(recommendations), "action_counts": action_counts, "elimination_reasons": _count_elimination_reasons(research_observations), "top": [ { "ts_code": r.ts_code, "name": r.name, "score": r.score, "action_plan": r.action_plan, "entry_signal_type": r.entry_signal_type, } for r in recommendations[:10] ], }, ) logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===") for r in recommendations[:5]: signal_map = {"breakout": "突破型", "breakout_confirm": "确认型", "pullback": "回踩型", "launch": "启动型", "reversal": "反转型"} signal_label = signal_map.get(r.entry_signal_type, r.entry_signal_type) logger.info(f" [{signal_label}] {r.name}({r.ts_code}) {r.level} 评分={r.score} 信号={r.signal}") return { "market_temp": market_temp, "hot_sectors": hot_sectors, "recommendations": recommendations, "scan_mode": scan_mode, "strategy_profile": strategy_profile.model_dump(), } async def _apply_catalyst_scores(sectors: list[SectorInfo]) -> list[SectorInfo]: if not sectors: return sectors try: scores = await build_theme_catalyst_scores(hours=72, limit=50) except Exception as e: logger.warning("催化分数加载失败,跳过主题催化加权: %s", e) return sectors if not scores: return sectors score_map = {item.theme_id: item for item in scores} name_map = {item.theme_name: item for item in scores} for sector in sectors: item = score_map.get(sector.theme_id) or name_map.get(sector.sector_name) if not item: continue sector.catalyst_score = item.catalyst_score sector.catalyst_count = item.catalyst_count sector.catalyst_reasons = item.top_reasons # 催化只增强主线优先级,不替代资金确认。 sector.heat_score = round(min(sector.heat_score + item.catalyst_score * 0.18, 100), 1) sectors.sort( key=lambda s: ( s.heat_score, s.catalyst_score, s.realtime_pct_change if s.realtime_pct_change is not None else s.pct_change, ), reverse=True, ) return sectors async def _select_from_hot_sectors( hot_sectors: list[SectorInfo], trade_date: str, intraday: bool, ) -> list[dict]: """主线主题轻召回。 这里只做基础清洗和活跃度排序,不再用“主力净流入必须为正”之类的硬门槛直接淘汰。 """ from datetime import datetime, timedelta if not trade_date: trade_date = tushare_client.get_latest_trade_date() sector_member_codes, sector_code_map, sector_stage_map, sector_rank_map, leader_codes = build_hot_theme_membership(hot_sectors) if not sector_member_codes: logger.info("Step 2: 主线主题轻召回无成分股数据") return [] logger.info(f"Step 2: 主线主题共 {len(sector_member_codes)} 只成分股") stock_basic = tushare_client.get_stock_basic() exclude_codes = set() name_map = {} industry_map = {} if not stock_basic.empty: st_codes = set(stock_basic[stock_basic["name"].str.contains("ST", na=False)]["ts_code"]) exclude_codes.update(st_codes) cutoff = (datetime.now() - timedelta(days=settings.min_list_days)).strftime("%Y%m%d") new_codes = set(stock_basic[stock_basic["list_date"] > cutoff]["ts_code"]) exclude_codes.update(new_codes) for _, row in stock_basic.iterrows(): name_map[row["ts_code"]] = row["name"] industry_map[row["ts_code"]] = row.get("industry", "") basic = tushare_client.get_daily_basic(trade_date) if basic.empty: logger.info("Step 2: daily_basic 无数据") return [] basic = basic.copy() basic["circ_mv"] = basic["circ_mv"] / 10000 filtered_basic = basic[ (basic["ts_code"].isin(sector_member_codes)) & (~basic["ts_code"].isin(exclude_codes)) & (basic["circ_mv"] >= settings.min_circ_mv) & (basic["circ_mv"] <= settings.max_circ_mv) & (basic["turnover_rate"] >= max(settings.min_turnover_rate * 0.5, 1.0)) & (basic["turnover_rate"] <= settings.max_turnover_rate * 1.2) ].copy() if filtered_basic.empty: logger.info("Step 2 主线主题轻召回严格过滤无结果,放宽换手率重试") filtered_basic = basic[ (basic["ts_code"].isin(sector_member_codes)) & (~basic["ts_code"].isin(exclude_codes)) & (basic["circ_mv"] >= settings.min_circ_mv) & (basic["circ_mv"] <= settings.max_circ_mv) ].copy() logger.info(f"Step 2 基本面过滤: {len(sector_member_codes)} 只 → {len(filtered_basic)} 只") if filtered_basic.empty: return [] mf = tushare_client.get_moneyflow_batch(trade_date) mf_lookup = {} if not mf.empty: mf["main_net_inflow"] = ( (mf["buy_elg_amount"] - mf["sell_elg_amount"]) + (mf["buy_lg_amount"] - mf["sell_lg_amount"]) ) total = ( mf["buy_elg_amount"] + mf["sell_elg_amount"] + mf["buy_lg_amount"] + mf["sell_lg_amount"] + mf["buy_md_amount"] + mf["sell_md_amount"] + mf["buy_sm_amount"] + mf["sell_sm_amount"] ) mf["inflow_ratio"] = (mf["main_net_inflow"] / total.replace(0, float("nan")) * 100).fillna(0) for _, row in mf.iterrows(): mf_lookup[row["ts_code"]] = { "main_net_inflow": float(row["main_net_inflow"]), "inflow_ratio": float(row.get("inflow_ratio", 0)), } candidates = [] for _, base_row in filtered_basic.iterrows(): ts_code = base_row["ts_code"] name = name_map.get(ts_code, ts_code) matched_sector = sector_code_map.get(ts_code, "") if not matched_sector: hot_match = find_hot_theme_match(industry_map.get(ts_code, ""), hot_sectors) matched_sector = hot_match.sector_name if hot_match else "" sector_name = matched_sector or industry_map.get(ts_code, "") mf_info = mf_lookup.get(ts_code, {}) turnover_rate = float(base_row["turnover_rate"]) if pd.notna(base_row.get("turnover_rate")) else 0 circ_mv = float(base_row["circ_mv"]) if pd.notna(base_row.get("circ_mv")) else 0 pe = float(base_row["pe"]) if pd.notna(base_row.get("pe")) else None pb = float(base_row["pb"]) if pd.notna(base_row.get("pb")) else None volume_ratio = float(base_row["volume_ratio"]) if pd.notna(base_row.get("volume_ratio")) else None main_net_inflow = float(mf_info.get("main_net_inflow", 0)) inflow_ratio = float(mf_info.get("inflow_ratio", 0)) sector_rank = sector_rank_map.get(sector_name, 99) recall_score = 30 if sector_rank <= 2: recall_score += 14 elif sector_rank <= 5: recall_score += 8 if sector_rank <= 5: recall_score += 12 if ts_code in leader_codes: recall_score += 14 if turnover_rate >= settings.min_turnover_rate: recall_score += 8 if volume_ratio and volume_ratio >= 1.2: recall_score += 8 if main_net_inflow > 0: recall_score += 8 elif main_net_inflow < 0: recall_score -= 4 recall_tags = ["hot_theme_core"] if ts_code in leader_codes: recall_tags.append("theme_leader") if main_net_inflow > 0: recall_tags.append("moneyflow_support") if volume_ratio and volume_ratio >= 1.5: recall_tags.append("volume_active") if sector_rank <= 3: recall_tags.append("top_theme_member") candidates.append({ "ts_code": ts_code, "name": name, "sector": sector_name, "sector_stage": sector_stage_map.get(sector_name, "mid"), "turnover_rate": turnover_rate, "circ_mv": circ_mv, "pe": pe, "pb": pb, "volume_ratio": volume_ratio, "main_net_inflow": main_net_inflow, "inflow_ratio": inflow_ratio, "recall_score": round(recall_score, 1), "recall_tags": recall_tags, "stock_role_hint": "主题领涨前排" if ts_code in leader_codes else ("主线主题成分" if sector_rank <= 3 else "主题活跃成分"), }) candidates.sort(key=lambda item: ( item.get("recall_score", 0), item.get("main_net_inflow", 0), item.get("turnover_rate", 0), ), reverse=True) top = candidates[: settings.candidate_pool_limit] logger.info(f"Step 2 主线主题轻召回: {len(top)} 只") return top async def _build_candidate_pool( hot_sectors: list[SectorInfo], trade_date: str | None, intraday: bool, market_temp: MarketTemperature, metrics: dict | None = None, ) -> list[dict]: """多路召回候选池。 目标是提高主线、资金、形态多路召回率,最终由规则评分统一排序。 """ merged: dict[str, dict] = {} sector_candidates = await _select_from_hot_sectors(hot_sectors, trade_date, intraday) _merge_candidate_batch(merged, sector_candidates, route="sector_recall") try: trend_candidates = await scan_trend_breakout( trade_date=trade_date, market_temp=market_temp, hot_sectors=hot_sectors, intraday=intraday, ) except Exception as e: logger.warning(f"趋势扫描召回失败: {e}") trend_candidates = [] _merge_candidate_batch(merged, trend_candidates, route="trend_scan") if intraday: try: intraday_candidates = await intraday_filter_stocks(hot_sectors) except Exception as e: logger.warning(f"盘中异动召回失败: {e}") intraday_candidates = [] _merge_candidate_batch(merged, intraday_candidates, route="intraday_active") try: realtime_candidates = await intraday_active_market_recall(limit=settings.candidate_pool_limit) except Exception as e: logger.warning(f"实时全市场召回失败: {e}") realtime_candidates = [] _merge_candidate_batch(merged, realtime_candidates, route="realtime_market") else: intraday_candidates = [] realtime_candidates = [] candidates = list(merged.values()) candidates.sort(key=lambda item: ( 1 if "sector_recall" in item.get("recall_tags", []) or "top_theme_member" in item.get("recall_tags", []) else 0, item.get("recall_score", 0), item.get("main_net_inflow", 0), item.get("turnover_rate", 0), item.get("volume_ratio", 0) or 0, ), reverse=True) top = candidates[: settings.candidate_pool_limit] logger.info( f"Step 2 多路召回完成: sector={len(sector_candidates)} " f"trend={len(trend_candidates)} " f"{'intraday=' + str(len(intraday_candidates)) + ' realtime=' + str(len(realtime_candidates)) if intraday else ''} " f"→ merged={len(top)}" ) if metrics is not None: route_counts = { "sector_recall": len(sector_candidates), "trend_scan": len(trend_candidates), "intraday_active": len(intraday_candidates), "realtime_market": len(realtime_candidates), } metrics.update({ "route_counts": route_counts, "raw_total": sum(route_counts.values()), "merged_count": len(candidates), "pool_limit": settings.candidate_pool_limit, "output_count": len(top), "deduplicated_count": max(sum(route_counts.values()) - len(candidates), 0), "top_candidates": [ { "ts_code": item.get("ts_code"), "name": item.get("name"), "sector": item.get("sector"), "recall_score": item.get("recall_score"), "recall_tags": item.get("recall_tags", []), } for item in top[:10] ], }) return top def _merge_candidate_batch(merged: dict[str, dict], items: list[dict], route: str) -> None: for item in items or []: ts_code = str(item.get("ts_code", "")).strip() if not ts_code: continue normalized = dict(item) normalized.setdefault("ts_code", ts_code) normalized.setdefault("name", ts_code) normalized.setdefault("sector", item.get("sector", "")) normalized.setdefault("sector_stage", item.get("sector_stage", "mid")) normalized.setdefault("recall_tags", []) normalized.setdefault("stock_role_hint", "待判断") normalized["recall_tags"] = list({*normalized.get("recall_tags", []), route}) normalized["recall_score"] = round( float(normalized.get("recall_score", 0) or 0) + _route_recall_weight(route, normalized), 1, ) existing = merged.get(ts_code) if not existing: merged[ts_code] = normalized continue existing["recall_tags"] = list({*existing.get("recall_tags", []), *normalized.get("recall_tags", [])}) existing["recall_score"] = round( min( 100, max(float(existing.get("recall_score", 0) or 0), float(normalized.get("recall_score", 0) or 0)) + min(float(normalized.get("recall_score", 0) or 0) * 0.2, 10), ), 1, ) for key, value in normalized.items(): if key in {"recall_tags", "recall_score"}: continue if existing.get(key) in (None, "", 0) and value not in (None, "", 0): existing[key] = value if len(existing.get("sector", "")) < len(normalized.get("sector", "")): existing["sector"] = normalized.get("sector", existing.get("sector", "")) def _route_recall_weight(route: str, item: dict) -> float: if route == "sector_recall": return 8 if route == "trend_scan": return min(float(item.get("entry_signal_score", 0) or 0) * 0.12, 12) if route == "intraday_active": return 12 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, hot_sectors: list[SectorInfo], market_temp_score: float = 0, intraday: bool = False, strategy_profile=None, metrics: dict | None = None, research_observations: list[dict] | None = None, scan_session: str = "manual", scan_mode: str = "", ) -> list[Recommendation]: """Step 3: 规则边界建模、评分与交易计划生成。""" from app.data.tushare_client import tushare_client from app.analysis.technical import add_all_indicators from app.analysis.breakout_signals import ( classify_entry_signal, score_supply_demand, EntrySignal, ) from app.analysis.signals import generate_signals from app.analysis.capital_flow import _score_valuation # 名称和行业映射 stock_basic = tushare_client.get_stock_basic() name_map = {} industry_map = {} if not stock_basic.empty: for _, row in stock_basic.iterrows(): name_map[row["ts_code"]] = row["name"] industry_map[row["ts_code"]] = row.get("industry", "") recommendations = [] total = len(candidates) skipped_counts = {"missing_code": 0, "kline_empty": 0, "stale_kline": 0, "exception": 0} signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0} score_weights = strategy_profile.score_weights if strategy_profile else { "catalyst": 0.30, "theme_money": 0.25, "stock_money": 0.20, "emotion_role": 0.15, "timing": 0.10, } score_weights = _normalize_score_weights(score_weights) signal_priority = strategy_profile.entry_signal_priority if strategy_profile else [] buy_threshold = strategy_profile.buy_threshold if strategy_profile else 60 for idx, stock in enumerate(candidates): ts_code = stock.get("ts_code", "") if not ts_code: skipped_counts["missing_code"] += 1 continue name = stock.get("name") or name_map.get(ts_code, ts_code) sector = stock.get("sector") or industry_map.get(ts_code, "") try: # 获取 120 日 K 线 df = tushare_client.get_stock_daily(ts_code, 120) if df.empty or len(df) < 30: skipped_counts["kline_empty"] += 1 continue # 数据新鲜度校验:最后一行必须是近 10 天内的数据 from datetime import datetime, timedelta last_date = str(df.iloc[-1]["trade_date"]) cutoff = (datetime.now() - timedelta(days=10)).strftime("%Y%m%d") if last_date < cutoff: logger.warning(f"K线数据过时 {ts_code}: 最新={last_date}, 需≥{cutoff}, 跳过") skipped_counts["stale_kline"] += 1 continue # 添加技术指标 df = add_all_indicators(df) # ── 入场信号分类 ── entry_signal = classify_entry_signal(df) signal_type = entry_signal["signal_type"] if signal_type == EntrySignal.NONE: signal_counts["none"] += 1 signal_name = "none" else: signal_name = signal_type.value signal_counts[signal_name] += 1 # ── 五轴评分:催化、主题资金、个股资金、情绪角色、入场时机 ── supply_demand_score = score_supply_demand(df) price_action_score = _score_price_action(df, entry_signal) trend_score = _score_trend(df) capital_score = _score_capital_simple(stock) flow_momentum_score = _score_flow_momentum(stock, sector, hot_sectors) sector_stage = _get_sector_stage(sector, hot_sectors) hot_theme_match = find_hot_theme_match(sector, hot_sectors) sector_limit_up = _get_sector_limit_up(sector, hot_sectors) catalyst_score = _get_sector_catalyst_score(sector, hot_sectors) catalyst_reasons = _get_sector_catalyst_reasons(sector, hot_sectors) theme_money_score = _score_theme_money(sector, hot_sectors, hot_theme_match) stock_money_score = _score_stock_money(stock, capital_score) emotion_role_score = _score_emotion_role( stock=stock, sector_limit_up=sector_limit_up, sector_stage=sector_stage, hot_theme_match=hot_theme_match, hot_sectors=hot_sectors, ) timing_score = _score_timing( entry_signal_score=entry_signal.get("signal_score", 0), price_action_score=price_action_score, trend_score=trend_score, position_score=50, ) last = df.iloc[-1] trend_penalty = 1.0 if all(c in df.columns for c in ["ma5", "ma10", "ma20"]): if not any(pd.isna(last[c]) for c in ["ma5", "ma10", "ma20"]): if last["ma5"] < last["ma10"] < last["ma20"]: trend_penalty = 0.82 scoring_axes = { "catalyst": catalyst_score, "theme_money": theme_money_score, "stock_money": stock_money_score, "emotion_role": emotion_role_score, "timing": timing_score, } final_score = sum(scoring_axes[key] * score_weights[key] for key in scoring_axes) final_score *= trend_penalty tech_signal = generate_signals(ts_code, name) if tech_signal: timing_score = _score_timing( entry_signal_score=entry_signal.get("signal_score", 0), price_action_score=price_action_score, trend_score=trend_score, position_score=tech_signal.position_score, ) scoring_axes["timing"] = timing_score final_score = sum(scoring_axes[key] * score_weights[key] for key in scoring_axes) final_score *= trend_penalty penalties = [] if tech_signal: if tech_signal.rally_pct_5d > 20: penalties.append(0.65) elif tech_signal.rally_pct_5d > 15: penalties.append(0.80) if sector_stage == "end": penalties.append(0.70) elif sector_stage == "late": penalties.append(0.88) if market_temp_score < 30: penalties.append(0.75) elif market_temp_score < 50: penalties.append(0.88) if penalties: final_score *= min(penalties) boosts = [] if sector_limit_up >= 5: final_score *= 1.20 boosts.append({"label": "板块涨停扩散", "value": "+20%", "reason": f"{sector_limit_up}家涨停"}) elif sector_limit_up >= 3: final_score *= 1.10 boosts.append({"label": "板块涨停扩散", "value": "+10%", "reason": f"{sector_limit_up}家涨停"}) if entry_signal.get("signal_score", 0) >= 80: final_score *= 1.10 boosts.append({"label": "入场形态强", "value": "+10%", "reason": f"信号分{entry_signal.get('signal_score', 0):.0f}"}) if catalyst_score >= 70 and hot_theme_match: final_score *= 1.06 boosts.append({"label": "新闻催化确认", "value": "+6%", "reason": catalyst_reasons[0] if catalyst_reasons else f"催化分{catalyst_score:.0f}"}) elif catalyst_score >= 45 and hot_theme_match: final_score *= 1.03 boosts.append({"label": "新闻催化加权", "value": "+3%", "reason": catalyst_reasons[0] if catalyst_reasons else f"催化分{catalyst_score:.0f}"}) flow_multiplier = _flow_confirmation_multiplier(stock, hot_theme_match, market_temp) final_score *= flow_multiplier if flow_multiplier > 1: boosts.append({"label": "资金主线共振", "value": f"+{round((flow_multiplier - 1) * 100)}%", "reason": "资金、量能与主线同向"}) elif flow_multiplier < 1: boosts.append({"label": "资金确认不足", "value": f"-{round((1 - flow_multiplier) * 100)}%", "reason": "资金或主线承接不足"}) theme_penalty = 1.0 if not hot_theme_match: theme_penalty = 0.82 final_score *= theme_penalty elif hot_theme_match not in hot_sectors[:5]: theme_penalty = 0.9 final_score *= theme_penalty signal_matches_profile = bool(signal_priority and signal_name in signal_priority[:4]) profile_multiplier = 1.0 if signal_type != EntrySignal.NONE and signal_priority: priority_rank = signal_priority.index(signal_type.value) if priority_rank == 0: profile_multiplier = 1.08 final_score *= profile_multiplier elif priority_rank == 1: profile_multiplier = 1.04 final_score *= profile_multiplier elif priority_rank >= 3: profile_multiplier = 0.94 final_score *= profile_multiplier if profile_multiplier != 1.0: boosts.append({ "label": "策略匹配度", "value": f"{'+' if profile_multiplier > 1 else '-'}{round(abs(profile_multiplier - 1) * 100)}%", "reason": f"{signal_name} 与今日策略优先级匹配", }) pe = stock.get("pe") pb = stock.get("pb") valuation_score = _score_valuation(pe, pb) level = _score_to_level(final_score) signal = "HOLD" position_score = tech_signal.position_score if tech_signal else 50 flow_confirmed = _is_flow_confirmed( stock=stock, flow_momentum_score=flow_momentum_score, trend_score=trend_score, price_action_score=price_action_score, ) effective_signal_name = signal_name if signal_name == "none" and flow_confirmed: effective_signal_name = "flow_momentum" if ( signal_type != EntrySignal.NONE and entry_signal.get("signal_score", 0) >= 50 and position_score >= 30 and final_score >= buy_threshold ) or ( flow_confirmed and position_score >= 30 and final_score >= buy_threshold + 2 ): signal = "BUY" entry_price = None target_price = None stop_loss = None if tech_signal: current_close = stock.get("price") or float(df.iloc[-1]["close"]) st = signal_type.value details = entry_signal.get("details", {}) entry_price = round(current_close, 2) if st == "breakout": resistance = details.get("resistance_price", 0) if resistance and resistance > 0: stop_loss = round(resistance * 0.99, 2) else: low_20 = float(df.tail(20)["low"].min()) stop_loss = round(low_20 * 0.99, 2) elif st == "pullback": support_ma = details.get("support_ma", "MA20") support_price = 0 if support_ma == "MA20" and not pd.isna(last.get("ma20")): support_price = last["ma20"] elif support_ma == "MA10" and not pd.isna(last.get("ma10")): support_price = last["ma10"] if support_price > 0: stop_loss = round(support_price * 0.985, 2) else: stop_loss = round(current_close * 0.97, 2) elif st == "reversal": low_5 = float(df.tail(5)["low"].min()) stop_loss = round(low_5 * 0.99, 2) elif st == "launch": if not pd.isna(last.get("ma20")) and last["ma20"] > 0: stop_loss = round(last["ma20"] * 0.98, 2) else: stop_loss = round(current_close * 0.97, 2) else: low_20 = float(df.tail(20)["low"].min()) stop_loss = round(min(low_20 * 0.99, current_close * 0.97), 2) high_20 = float(df.tail(20)["high"].max()) high_60 = float(df.tail(60)["high"].max()) if len(df) >= 60 else high_20 if st == "breakout": if high_60 > current_close: target_price = round(min(high_60 * 0.98, entry_price * 1.08), 2) else: target_price = round(entry_price * 1.05, 2) elif st == "launch": target_price = round(min(high_20 * 1.03, entry_price * 1.08), 2) elif st == "reversal": target_price = round(min(high_20 * 0.98, entry_price * 1.08), 2) elif st == "pullback": target_price = round(min(high_20 * 0.98, entry_price * 1.05), 2) else: target_price = round(min(high_20 * 0.98, entry_price * 1.05), 2) max_stop_pct = 0.08 if stop_loss < entry_price * (1 - max_stop_pct): stop_loss = round(entry_price * (1 - max_stop_pct), 2) min_stop_pct = 0.02 if stop_loss > entry_price * (1 - min_stop_pct): stop_loss = round(entry_price * (1 - min_stop_pct), 2) min_target_pct = 0.03 if target_price < entry_price * (1 + min_target_pct): target_price = round(entry_price * (1 + min_target_pct), 2) stock["entry_signal_type"] = effective_signal_name reasons = _generate_reasons(stock, entry_signal, tech_signal, df, intraday) risk_note = _generate_risk_note(market_temp, tech_signal, stock) entry_timing = _generate_entry_timing(effective_signal_name, intraday) trade_plan = _build_trade_plan( signal_type=effective_signal_name, score=final_score, market_temp=market_temp, sector_stage=sector_stage, entry_price=entry_price, target_price=target_price, stop_loss=stop_loss, entry_timing=entry_timing, data_date=last_date, ) risk_tags = _build_risk_tags(market_temp, tech_signal, sector_stage, trend_penalty) penalty_notes = _build_penalty_notes( penalties=penalties, trend_penalty=trend_penalty, theme_penalty=theme_penalty, market_temp_score=market_temp_score, sector_stage=sector_stage, hot_theme_match=hot_theme_match, ) decision_trace = _build_decision_trace( stock=stock, score=final_score, score_weights=score_weights, scoring_axes=scoring_axes, flow_momentum_score=flow_momentum_score, supply_demand_score=supply_demand_score, price_action_score=price_action_score, trend_score=trend_score, capital_score=capital_score, position_score=position_score, valuation_score=valuation_score, entry_signal_type=effective_signal_name, entry_signal_score=entry_signal.get("signal_score", 0), signal_matches_profile=signal_matches_profile, sector_stage=sector_stage, sector_limit_up=sector_limit_up, catalyst_score=catalyst_score, catalyst_reasons=catalyst_reasons, market_temp=market_temp, trade_plan=trade_plan, boosts=boosts, penalties=penalty_notes, risk_tags=risk_tags, hot_theme_match=hot_theme_match, ) rec = Recommendation( ts_code=ts_code, name=name, sector=sector, score=round(final_score, 1), market_temp_score=round(market_temp_score, 1), sector_score=round(_get_sector_heat(sector, hot_sectors), 1), capital_score=round(capital_score, 1), technical_score=round(trend_score, 1), supply_demand_score=round(supply_demand_score, 1), price_action_score=round(price_action_score, 1), position_score=round(position_score, 1), valuation_score=round(valuation_score, 1), signal=signal, entry_price=entry_price, target_price=target_price, stop_loss=stop_loss, reasons=reasons, risk_note=risk_note, level=level, strategy=strategy_profile.strategy_id if strategy_profile else "trend_breakout", entry_signal_type=effective_signal_name, entry_timing=entry_timing, action_plan=trade_plan["action_plan"], trigger_condition=trade_plan["trigger_condition"], invalidation_condition=trade_plan["invalidation_condition"], suggested_position_pct=trade_plan["suggested_position_pct"], review_after_days=trade_plan["review_after_days"], lifecycle_status=trade_plan["lifecycle_status"], data_freshness=trade_plan["data_freshness"], recall_tags=stock.get("recall_tags", []), prefilter_decision="", prefilter_reason="", focus_points=[], decision_trace=decision_trace, ) recommendations.append(rec) if research_observations is not None: research_observations.append(_build_research_observation( scan_session=scan_session, scan_mode=scan_mode, stock=stock, rec=rec, scoring_axes=scoring_axes, flow_momentum_score=flow_momentum_score, entry_signal_score=entry_signal.get("signal_score", 0), sector_stage=sector_stage, sector_limit_up=sector_limit_up, catalyst_reasons=catalyst_reasons, hot_theme_match=hot_theme_match, market_temp=market_temp, score_weights=score_weights, boosts=boosts, penalties=penalty_notes, risk_tags=risk_tags, )) except Exception as e: logger.debug(f"规则分析 {ts_code} 失败: {e}") skipped_counts["exception"] += 1 continue logger.info( f"Step 3 入场信号分布: " f"突破={signal_counts['breakout']} 确认={signal_counts['breakout_confirm']} " f"回踩={signal_counts['pullback']} 启动={signal_counts['launch']} " f"反转={signal_counts['reversal']} 无信号={signal_counts['none']} " f"(共分析{total}只)" ) recommendations.sort(key=lambda rec: rec.score, reverse=True) if metrics is not None: action_counts = {"可操作": 0, "重点关注": 0, "观察": 0} for rec in recommendations: action_counts[rec.action_plan] = action_counts.get(rec.action_plan, 0) + 1 metrics.update({ "input_count": total, "analyzed_count": total - sum(skipped_counts.values()), "output_count": len(recommendations), "skipped_counts": skipped_counts, "signal_counts": signal_counts, "action_counts_before_final_filter": action_counts, "score_top": [ { "ts_code": rec.ts_code, "name": rec.name, "sector": rec.sector, "score": rec.score, "action_plan": rec.action_plan, "entry_signal_type": rec.entry_signal_type, } for rec in recommendations[:10] ], }) return recommendations # ── 主评分辅助 ── def _normalize_score_weights(weights: dict[str, float]) -> dict[str, float]: """归一化五轴主评分权重,并兼容旧四项策略配置。""" defaults = { "catalyst": 0.30, "theme_money": 0.25, "stock_money": 0.20, "emotion_role": 0.15, "timing": 0.10, } raw = weights or {} if any(key in raw for key in defaults): merged = {**defaults, **raw} else: merged = { "catalyst": defaults["catalyst"], "theme_money": max(float(raw.get("capital_momentum", 0) or 0), 0), "stock_money": max(float(raw.get("supply_demand", 0) or 0), 0), "emotion_role": max(float(raw.get("trend", 0) or 0), 0), "timing": max(float(raw.get("price_action", 0) or 0), 0), } keys = list(defaults.keys()) total = sum(max(float(merged.get(k, 0) or 0), 0) for k in keys) if total <= 0: return defaults return {k: max(float(merged.get(k, 0) or 0), 0) / total for k in keys} def _score_theme_money( sector_name: str, hot_sectors: list[SectorInfo], hot_theme_match: SectorInfo | None, ) -> float: theme = hot_theme_match if not theme: theme = next((s for s in hot_sectors if s.sector_name == sector_name), None) if not theme: return 20.0 pct = theme.realtime_pct_change if theme.realtime_pct_change is not None else theme.pct_change amount = theme.realtime_amount if theme.realtime_amount is not None else theme.capital_inflow main_force_ratio = theme.main_force_ratio or 0 up = theme.realtime_up_count down = theme.realtime_down_count score = min(max(theme.heat_score, 0), 100) * 0.42 if pct >= 4: score += 18 elif pct >= 2: score += 14 elif pct > 0: score += 8 elif pct < -1: score -= 8 if amount > 500000: score += 14 elif amount > 200000: score += 10 elif amount > 0: score += 6 if main_force_ratio >= 20: score += 10 elif main_force_ratio >= 10: score += 6 elif main_force_ratio < 0: score -= 6 if up is not None and down is not None: breadth = up - down if breadth >= 20: score += 10 elif breadth >= 8: score += 6 elif breadth < -8: score -= 8 return round(max(0, min(score, 100)), 1) def _score_stock_money(stock: dict, capital_score: float) -> float: main_net = float(stock.get("main_net_inflow", 0) or 0) inflow_ratio = float(stock.get("inflow_ratio", 0) or 0) turnover_rate = float(stock.get("turnover_rate", 0) or 0) volume_ratio = stock.get("volume_ratio") volume_ratio = float(volume_ratio) if volume_ratio not in (None, "") else 0.0 score = capital_score * 0.55 if main_net > 15000: score += 18 elif main_net > 8000: score += 14 elif main_net > 3000: score += 10 elif main_net < -5000: score -= 12 if inflow_ratio > 12: score += 12 elif inflow_ratio > 6: score += 8 elif inflow_ratio < -6: score -= 8 if volume_ratio >= 2: score += 10 elif volume_ratio >= 1.2: score += 6 if 3 <= turnover_rate <= 15: score += 8 elif turnover_rate > 0: score += 3 return round(max(0, min(score, 100)), 1) def _score_emotion_role( stock: dict, sector_limit_up: int, sector_stage: str, hot_theme_match: SectorInfo | None, hot_sectors: list[SectorInfo], ) -> float: tags = set(stock.get("recall_tags", []) or []) recall_score = float(stock.get("recall_score", 0) or 0) score = min(max(recall_score, 0), 100) * 0.45 if hot_theme_match: try: rank = hot_sectors.index(hot_theme_match) + 1 except ValueError: rank = 99 if rank == 1: score += 16 elif rank <= 3: score += 12 elif rank <= 5: score += 7 else: score -= 10 if "theme_leader" in tags: score += 18 elif "top_theme_member" in tags: score += 10 elif "hot_theme_core" in tags: score += 6 if sector_limit_up >= 5: score += 14 elif sector_limit_up >= 3: score += 10 elif sector_limit_up >= 1: score += 5 if sector_stage == "early": score += 8 elif sector_stage == "mid": score += 5 elif sector_stage == "late": score -= 8 elif sector_stage == "end": score -= 20 return round(max(0, min(score, 100)), 1) def _score_timing( entry_signal_score: float, price_action_score: float, trend_score: float, position_score: float, ) -> float: score = ( min(max(entry_signal_score, 0), 100) * 0.40 + min(max(price_action_score, 0), 100) * 0.28 + min(max(trend_score, 0), 100) * 0.17 + min(max(position_score, 0), 100) * 0.15 ) return round(max(0, min(score, 100)), 1) def _score_flow_momentum(stock: dict, sector_name: str, hot_sectors: list[SectorInfo]) -> float: """资金顺势评分:个股资金在场 + 主线板块顺风 + 活跃度确认。""" main_net = float(stock.get("main_net_inflow", 0) or 0) inflow_ratio = float(stock.get("inflow_ratio", 0) or 0) turnover_rate = float(stock.get("turnover_rate", 0) or 0) volume_ratio = stock.get("volume_ratio") volume_ratio = float(volume_ratio) if volume_ratio not in (None, "") else 0.0 recall_score = float(stock.get("recall_score", 0) or 0) sector_heat = _get_sector_heat(sector_name, hot_sectors) sector_limit_up = _get_sector_limit_up(sector_name, hot_sectors) catalyst_score = _get_sector_catalyst_score(sector_name, hot_sectors) score = 0.0 # 个股主力资金占 40 分。负流入不一票否决,但会明显降权。 if main_net > 15000: score += 28 elif main_net > 8000: score += 24 elif main_net > 3000: score += 18 elif main_net > 0: score += 10 elif main_net < -5000: score -= 12 elif main_net < 0: score -= 6 if inflow_ratio > 12: score += 12 elif inflow_ratio > 8: score += 9 elif inflow_ratio > 4: score += 6 elif inflow_ratio > 0: score += 3 elif inflow_ratio < -8: score -= 8 # 主线板块和涨停广度占 25 分。 if sector_heat >= 80: score += 16 elif sector_heat >= 65: score += 12 elif sector_heat >= 50: score += 8 elif sector_heat >= 35: score += 4 if sector_limit_up >= 5: score += 9 elif sector_limit_up >= 3: score += 6 elif sector_limit_up >= 1: score += 3 if catalyst_score >= 80: score += 8 elif catalyst_score >= 60: score += 6 elif catalyst_score >= 40: score += 3 # 活跃度和召回强度占 35 分。 if volume_ratio >= 2.5: score += 12 elif volume_ratio >= 1.8: score += 9 elif volume_ratio >= 1.2: score += 6 elif volume_ratio > 0: score += 2 if 4 <= turnover_rate <= 12: score += 10 elif 2 <= turnover_rate <= 20: score += 7 elif turnover_rate > 0: score += 3 score += min(max(recall_score, 0), 100) * 0.13 return round(max(0, min(score, 100)), 1) def _flow_confirmation_multiplier(stock: dict, hot_theme_match: SectorInfo | None, market_temp: MarketTemperature) -> float: """资金与主线共振时加分,资金背离时降权。""" main_net = float(stock.get("main_net_inflow", 0) or 0) inflow_ratio = float(stock.get("inflow_ratio", 0) or 0) volume_ratio = stock.get("volume_ratio") volume_ratio = float(volume_ratio) if volume_ratio not in (None, "") else 0.0 multiplier = 1.0 if hot_theme_match and main_net > 3000 and inflow_ratio > 3: multiplier += 0.05 if hot_theme_match and volume_ratio >= 1.5 and market_temp.temperature >= 50: multiplier += 0.04 if main_net < -3000 and inflow_ratio < -3: multiplier -= 0.10 elif main_net < 0 and not hot_theme_match: multiplier -= 0.06 return max(0.82, min(multiplier, 1.12)) def _is_flow_confirmed( stock: dict, flow_momentum_score: float, trend_score: float, price_action_score: float, ) -> bool: """允许资金顺势票进入候选,不必等 RSI/MACD 等滞后指标确认。""" main_net = float(stock.get("main_net_inflow", 0) or 0) inflow_ratio = float(stock.get("inflow_ratio", 0) or 0) volume_ratio = stock.get("volume_ratio") volume_ratio = float(volume_ratio) if volume_ratio not in (None, "") else 0.0 tags = set(stock.get("recall_tags", []) or []) return ( flow_momentum_score >= 72 and main_net > 2000 and inflow_ratio > 2 and volume_ratio >= 1.15 and trend_score >= 45 and price_action_score >= 42 and bool(tags & {"hot_theme_core", "theme_leader", "top_theme_member", "sector_recall"}) ) # ── 价格行为评分 ── def _score_price_action(df, entry_signal: dict) -> float: """价格行为学评分 (0-100) 纯粹关注 K 线形态和量价配合,不重复评估趋势/均线因素。 维度: - K线形态强度 (35): 实体占比、收盘位置、下影线 - 量价配合 (35): 放量/缩量与价格方向的配合度 - 入场形态质量 (30): 各信号类型的形态完成度 """ import pandas as pd score = 0 last = df.iloc[-1] details = entry_signal.get("details", {}) signal_type = entry_signal.get("signal_type") # K线形态强度 (35) day_range = last["high"] - last["low"] if day_range > 0: # 实体占比(实体/全振幅) body = abs(last["close"] - last["open"]) body_ratio = body / day_range if body_ratio > 0.7: score += 20 # 大实体,方向明确 elif body_ratio > 0.4: score += 12 elif body_ratio > 0.2: score += 6 # 收盘位置(越接近高点越好) close_position = (last["close"] - last["low"]) / day_range if close_position > 0.8: score += 10 # 收在上部 20% elif close_position > 0.6: score += 6 elif close_position > 0.4: score += 3 # 下影线(回踩型/启动型利好) lower_wick = (last["open"] - last["low"]) if last["close"] > last["open"] else (last["close"] - last["low"]) if lower_wick > 0: wick_ratio = lower_wick / day_range if signal_type and signal_type.value in ("pullback", "reversal") and wick_ratio > 0.2: score += 5 # 回踩型/反转型有下影线支撑 # 量价配合 (35) vol_ma_col = "vol_ma5" if "vol_ma5" in df.columns else None if vol_ma_col and not pd.isna(last[vol_ma_col]) and last[vol_ma_col] > 0: vol_ratio = last["vol"] / last[vol_ma_col] price_up = last["pct_chg"] > 0 if "pct_chg" in df.columns else last["close"] > last["open"] if price_up and vol_ratio > 2.0: score += 35 # 放量大阳 elif price_up and vol_ratio > 1.5: score += 25 elif price_up and vol_ratio > 1.2: score += 18 elif not price_up and vol_ratio < 0.7: score += 25 # 缩量回调(良性) elif not price_up and vol_ratio < 0.9: score += 15 elif price_up and vol_ratio > 1.0: score += 10 else: score += 10 # 入场形态质量 (30) — 只评估形态完成度,不涉及均线/MACD if signal_type and signal_type.value == "breakout": breakout_pct = details.get("breakout_pct", 0) vol_ratio = details.get("volume_ratio", 1) if breakout_pct > 2 and vol_ratio > 2: score += 30 elif breakout_pct > 1 and vol_ratio > 1.5: score += 20 elif breakout_pct > 0: score += 12 else: score += 6 elif signal_type and signal_type.value == "breakout_confirm": vol_ratio = details.get("volume_ratio", 1) confirm_pct = details.get("confirm_pct", 0) if vol_ratio > 2 and confirm_pct > 2: score += 30 elif vol_ratio > 1.5 and confirm_pct > 1: score += 22 elif vol_ratio > 1.0: score += 14 else: score += 8 elif signal_type and signal_type.value == "pullback": support_ma = details.get("support_ma", "") shrink = details.get("volume_shrink_ratio", 1) if support_ma == "MA20" and shrink < 0.6: score += 30 elif support_ma == "MA20": score += 22 elif support_ma == "MA10" and shrink < 0.6: score += 18 else: score += 10 elif signal_type and signal_type.value == "launch": range_pct = details.get("price_range_pct", 10) if range_pct < 3: score += 30 elif range_pct < 5: score += 20 else: score += 10 elif signal_type and signal_type.value == "reversal": reversal_pct = details.get("reversal_pct", 0) vol_ratio = details.get("volume_ratio", 1) if reversal_pct > 5 and vol_ratio > 2.5: score += 30 elif reversal_pct > 3 and vol_ratio > 2: score += 22 elif reversal_pct > 3: score += 14 else: score += 8 else: score += 10 return min(score, 100) # ── 趋势评分 ── def _score_trend(df) -> float: """趋势评分 (0-100) 维度: - 均线排列 (40): MA5>MA10>MA20>MA60 - 更高高点/更高低点结构 (35): 近 20 日价格结构 - MA20 方向 (25): MA20 是否持续上行 """ import pandas as pd score = 0 last = df.iloc[-1] # 均线排列 (40) ma_cols = [c for c in ["ma5", "ma10", "ma20", "ma60"] if c in df.columns] if len(ma_cols) >= 4 and not any(pd.isna(last[c]) for c in ma_cols): if last["ma5"] > last["ma10"] > last["ma20"] > last["ma60"]: score += 40 # 完美多头 elif last["ma5"] > last["ma10"] > last["ma20"]: score += 28 elif last["ma5"] > last["ma20"]: score += 15 elif "ma5" in df.columns and "ma20" in df.columns: if not pd.isna(last["ma5"]) and not pd.isna(last["ma20"]) and last["ma5"] > last["ma20"]: score += 15 # 更高高点/更高低点结构 (35) if len(df) >= 20: recent = df.tail(20) # 检查高点抬升 first_10_high = recent["high"].iloc[:10].max() second_10_high = recent["high"].iloc[10:].max() # 检查低点抬升 first_10_low = recent["low"].iloc[:10].min() second_10_low = recent["low"].iloc[10:].min() if second_10_high > first_10_high and second_10_low > first_10_low: score += 35 # 既抬高点又抬低点,最健康 elif second_10_high > first_10_high: score += 20 # 至少高点抬升 elif second_10_low > first_10_low: score += 12 # 至少低点抬升 # MA20 方向 (25) if "ma20" in df.columns and len(df) >= 5: ma20_now = last["ma20"] ma20_5d = df.iloc[-5]["ma20"] if not pd.isna(ma20_now) and not pd.isna(ma20_5d) and ma20_5d > 0: ma20_pct = (ma20_now - ma20_5d) / ma20_5d * 100 if ma20_pct > 2: score += 25 elif ma20_pct > 1: score += 18 elif ma20_pct > 0: score += 10 return min(score, 100) # ── 辅助函数 ── def _get_sector_stage(sector_name: str, hot_sectors: list[SectorInfo]) -> str: """获取板块所处阶段""" for s in hot_sectors: if s.sector_name == sector_name: return s.stage return "mid" def _get_sector_heat(sector_name: str, hot_sectors: list[SectorInfo]) -> float: """获取板块热度得分""" for s in hot_sectors: if s.sector_name == sector_name: return s.heat_score return 30.0 def _get_sector_limit_up(sector_name: str, hot_sectors: list[SectorInfo]) -> int: """获取板块涨停数""" for s in hot_sectors: if s.sector_name == sector_name: return s.limit_up_count return 0 def _get_sector_catalyst_score(sector_name: str, hot_sectors: list[SectorInfo]) -> float: for s in hot_sectors: if s.sector_name == sector_name: return s.catalyst_score return 0.0 def _get_sector_catalyst_reasons(sector_name: str, hot_sectors: list[SectorInfo]) -> list[str]: for s in hot_sectors: if s.sector_name == sector_name: return s.catalyst_reasons return [] def _get_sector_member_count(sector_name: str, hot_sectors: list[SectorInfo]) -> int: """获取板块成分股数量""" for s in hot_sectors: if s.sector_name == sector_name: return s.member_count return 0 def _score_capital_simple(stock: dict) -> float: """资金流简单评分(仅基于已有数据,不额外调 API)""" main_net = stock.get("main_net_inflow", 0) or 0 inflow_ratio = stock.get("inflow_ratio", 0) or 0 score = 0 if main_net > 10000: score += 60 elif main_net > 5000: score += 45 elif main_net > 2000: score += 30 elif main_net > 0: score += 15 if inflow_ratio > 15: score += 40 elif inflow_ratio > 10: score += 30 elif inflow_ratio > 5: score += 20 elif inflow_ratio > 0: score += 10 return min(score, 100) def _generate_entry_timing(signal_type: str, intraday: bool) -> str: """根据信号类型生成进场时机建议""" if not intraday: return "" # 盘后模式不需要时机建议 timing_map = { "breakout": "开盘观察是否站稳突破位,午后14:00确认不回落再进场", "breakout_confirm": "突破已确认,盘中放量时可直接进场", "pullback": "盘中靠近支撑位时分批进场,尾盘14:30确认支撑有效可加仓", "launch": "早盘放量确认后即可进场,注意开盘9:30-10:00量能", "reversal": "午后13:30确认不回落再进场,避免早盘追高", "flow_momentum": "优先看资金是否延续流入和板块前排是否继续走强,分时承接确认后再分批", } return timing_map.get(signal_type, "盘中观察量价配合,确认信号后进场") def _build_trade_plan( signal_type: str, score: float, market_temp: MarketTemperature, sector_stage: str, entry_price: float | None, target_price: float | None, stop_loss: float | None, entry_timing: str, data_date: str, ) -> dict: """把推荐转成可执行计划。 这里不替代用户决策,只把系统推荐拆成触发、失效、仓位和复盘窗口。 """ signal_label = { "breakout": "放量突破", "breakout_confirm": "突破确认", "pullback": "回踩支撑", "launch": "缩量整理后启动", "reversal": "放量反转", "flow_momentum": "资金顺势确认", }.get(signal_type, "资金与量价信号") if market_temp.temperature < 40 or sector_stage in ("end",): action_plan = "观察" lifecycle_status = "candidate" elif score >= 84 and market_temp.temperature >= 62 and sector_stage == "early": action_plan = "可操作" lifecycle_status = "actionable" 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" if action_plan == "可操作": base_position = 20 elif action_plan == "重点关注": base_position = 10 else: base_position = 0 if market_temp.temperature >= 70: base_position += 5 elif market_temp.temperature < 50: base_position -= 5 if sector_stage == "late": base_position -= 5 suggested_position_pct = max(0, min(base_position, 30)) price_part = f"参考价 {entry_price}" if entry_price else "参考当前价" timing_part = entry_timing or "等待量价确认" trigger_condition = f"{signal_label}成立且不跌破关键价位,{price_part}附近分批关注;{timing_part}" invalid_parts = [] if stop_loss: invalid_parts.append(f"跌破止损 {stop_loss}") if entry_price: invalid_parts.append(f"收盘跌回参考价 {round(entry_price * 0.98, 2)} 下方") if target_price: invalid_parts.append(f"冲高接近目标 {target_price} 后量能衰减") if market_temp.temperature < 45: invalid_parts.append("市场温度继续走弱") invalidation_condition = ";".join(invalid_parts) or "信号次日未延续或板块热度退潮" review_after_days = 1 if signal_type in ("breakout", "reversal") else 3 data_freshness = f"K线数据日期 {data_date};盘中价格优先使用腾讯实时行情" return { "action_plan": action_plan, "trigger_condition": trigger_condition, "invalidation_condition": invalidation_condition, "suggested_position_pct": suggested_position_pct, "review_after_days": review_after_days, "lifecycle_status": lifecycle_status, "data_freshness": data_freshness, } def _score_to_level(score: float) -> str: if score >= 80: return "强烈推荐" elif score >= 60: return "推荐" elif score >= 40: return "观望" else: return "回避" def _derive_stock_role(stock: dict, hot_theme_match: SectorInfo | None) -> str: tags = set(stock.get("recall_tags", []) or []) if "theme_leader" in tags: return "龙头/前排" if "top_theme_member" in tags: return "主题前排" if "intraday_active" in tags or "realtime_active" in tags or "realtime_market" in tags: return "盘中异动" if hot_theme_match: return "主线成分" return "观察候选" def _build_research_observation( *, scan_session: str, scan_mode: str, stock: dict, rec: Recommendation, scoring_axes: dict[str, float], flow_momentum_score: float, entry_signal_score: float, sector_stage: str, sector_limit_up: int, catalyst_reasons: list[str], hot_theme_match: SectorInfo | None, market_temp: MarketTemperature, score_weights: dict[str, float], boosts: list[dict], penalties: list[dict], risk_tags: list[str], ) -> dict: theme_name = hot_theme_match.sector_name if hot_theme_match else rec.sector stock_role = _derive_stock_role(stock, hot_theme_match) detail = { "market": { "temperature": round(market_temp.temperature, 1), "up_count": market_temp.up_count, "down_count": market_temp.down_count, "limit_up_count": market_temp.limit_up_count, "broken_rate": market_temp.broken_rate, }, "theme": { "name": theme_name, "matched": bool(hot_theme_match), "stage": sector_stage, "limit_up_count": sector_limit_up, "heat_score": rec.sector_score, "catalyst_reasons": catalyst_reasons[:3], }, "stock": { "role": stock_role, "recall_score": stock.get("recall_score", 0), "recall_tags": stock.get("recall_tags", []), "main_net_inflow": stock.get("main_net_inflow", 0), "inflow_ratio": stock.get("inflow_ratio", 0), "turnover_rate": stock.get("turnover_rate", 0), "volume_ratio": stock.get("volume_ratio"), "circ_mv": stock.get("circ_mv"), }, "scores": { "weights": score_weights, "axes": scoring_axes, "flow_momentum": flow_momentum_score, "entry_signal_score": entry_signal_score, "final_score": rec.score, }, "decision": { "action_plan": rec.action_plan, "signal": rec.signal, "entry_signal_type": rec.entry_signal_type, "trigger_condition": rec.trigger_condition, "invalidation_condition": rec.invalidation_condition, "risk_note": rec.risk_note, "boosts": boosts[:4], "penalties": penalties[:4], "risk_tags": risk_tags, }, } return { "scan_session": scan_session, "scan_mode": scan_mode, "ts_code": rec.ts_code, "name": rec.name, "theme_name": theme_name, "stock_role": stock_role, "action_plan": rec.action_plan, "final_score": rec.score, "catalyst_score": scoring_axes.get("catalyst", 0), "theme_money_score": scoring_axes.get("theme_money", 0), "stock_money_score": scoring_axes.get("stock_money", 0), "emotion_role_score": scoring_axes.get("emotion_role", 0), "timing_score": scoring_axes.get("timing", 0), "entry_signal_type": rec.entry_signal_type, "elimination_reason": "", "detail": detail, } def _build_final_filter_reasons( recommendations: list[Recommendation], strategy_profile: StrategyProfile, ) -> dict[str, str]: reasons = {} for rec in recommendations: reason_parts = [] if not _is_main_theme_recommendation(rec): reason_parts.append("非主线候选") if rec.score < strategy_profile.min_score: reason_parts.append(f"低于保留线{strategy_profile.min_score:.0f}") if rec.action_plan == "观察": reason_parts.append("仅观察档") elif rec.action_plan == "重点关注": reason_parts.append("关注未入最终池") elif rec.action_plan == "可操作": reason_parts.append("可操作但名额/风控限制") reasons[rec.ts_code] = ";".join(reason_parts) or "最终名额限制" return reasons def _apply_final_research_outcomes( *, observations: list[dict], final_codes: set[str], final_filter_reasons: dict[str, str], min_score: float, ) -> None: for item in observations: ts_code = item.get("ts_code", "") if ts_code in final_codes: item["elimination_reason"] = "进入最终作战池" item.setdefault("detail", {}).setdefault("decision", {})["final_outcome"] = "kept" continue reason = final_filter_reasons.get(ts_code) or f"未达到保留线{min_score:.0f}" item["elimination_reason"] = reason item.setdefault("detail", {}).setdefault("decision", {})["final_outcome"] = "filtered" item["detail"]["decision"]["elimination_reason"] = reason def _count_elimination_reasons(observations: list[dict]) -> dict[str, int]: counts: dict[str, int] = {} for item in observations: reason = item.get("elimination_reason") or "未知" for part in str(reason).split(";"): if not part: continue counts[part] = counts.get(part, 0) + 1 return counts def _generate_reasons( stock: dict, entry_signal: dict, tech: TechnicalSignal | None, df, intraday: bool = False, ) -> list[str]: """生成推荐理由""" import pandas as pd from app.analysis.breakout_signals import EntrySignal reasons = [] signal_type = entry_signal.get("signal_type") details = entry_signal.get("details", {}) signal_map = {EntrySignal.BREAKOUT: "突破型", EntrySignal.BREAKOUT_CONFIRM: "确认型", EntrySignal.PULLBACK: "回踩型", EntrySignal.LAUNCH: "启动型", EntrySignal.REVERSAL: "反转型"} entry_label = signal_map.get(signal_type, "") if stock.get("entry_signal_type") == "flow_momentum": entry_label = "资金顺势型" # 入场信号 if entry_label and stock.get("entry_signal_type") == "flow_momentum": reasons.append("主线主题内资金顺势增强,优先跟踪承接而非等待滞后指标") elif entry_label and signal_type: st = signal_type.value if st == "breakout": breakout_pct = details.get("breakout_pct", 0) vol_ratio = details.get("volume_ratio", 0) reasons.append(f"放量突破20日阻力位(涨幅{breakout_pct:.1f}%,量比{vol_ratio:.1f}倍)") elif st == "breakout_confirm": vol_ratio = details.get("volume_ratio", 0) confirm_pct = details.get("confirm_pct", 0) reasons.append(f"突破后放量确认(确认日涨{confirm_pct:.1f}%,量比{vol_ratio:.1f}倍)") elif st == "pullback": support = details.get("support_ma", "") shrink = details.get("volume_shrink_ratio", 0) reasons.append(f"缩量回踩{support}支撑(量能收缩至{shrink:.0%})") elif st == "launch": range_pct = details.get("price_range_pct", 0) reasons.append(f"缩量横盘整理{range_pct:.1f}%后首日放量启动") elif st == "reversal": reversal_pct = details.get("reversal_pct", 0) vol_ratio = details.get("volume_ratio", 0) reasons.append(f"连续下跌后放量长阳反转(涨{reversal_pct:.1f}%,量比{vol_ratio:.1f}倍)") # 供需分析 if len(df) >= 10: recent = df.tail(10) up_days = recent[recent["pct_chg"] > 0] down_days = recent[recent["pct_chg"] <= 0] if len(up_days) > 0 and len(down_days) > 0: avg_up_vol = up_days["vol"].mean() avg_down_vol = down_days["vol"].mean() if avg_down_vol > 0: ds_ratio = avg_up_vol / avg_down_vol if ds_ratio > 1.5: reasons.append(f"需求主导(上涨均量/下跌均量={ds_ratio:.1f})") # 资金流 main_net = stock.get("main_net_inflow", 0) if main_net > 5000: reasons.append(f"主力资金大幅流入{main_net:.0f}万元") elif main_net > 1000: reasons.append(f"主力资金持续流入{main_net:.0f}万元") # 板块 sector = stock.get("sector", "") if sector: reasons.append(f"所属主线主题【{sector}】") return reasons[:3] def _generate_risk_note( market: MarketTemperature, tech: TechnicalSignal | None, stock: dict, ) -> str: """生成风险提示""" notes = [] entry_type = stock.get("entry_signal_type", "") if entry_type == "breakout": notes.append("突破型需警惕假突破,关注量能是否持续") elif entry_type == "breakout_confirm": notes.append("确认型需观察后续量能是否跟上,防止冲高回落") elif entry_type == "pullback": notes.append("回踩型可能继续下探支撑,注意止损纪律") elif entry_type == "launch": notes.append("启动型整理可能延长,注意时间成本") elif entry_type == "reversal": notes.append("反转型可能二次探底,确认底部后再加仓") elif entry_type == "flow_momentum": notes.append("资金顺势型需防板块分歧和资金一日游,重点看次日承接") if market.temperature < 30: notes.append("市场情绪偏冷,系统性风险较高") elif market.temperature < 50: notes.append("市场情绪一般,注意仓位控制") if tech: if tech.position_score < 30: notes.append(f"近期涨幅较大(5日{tech.rally_pct_5d}%),追高风险") if tech.rally_pct_10d > 20: notes.append(f"10日累涨{tech.rally_pct_10d}%,警惕回调") if not notes: return "注意设好止损,控制仓位" return ";".join(notes) def _build_risk_tags( market: MarketTemperature, tech: TechnicalSignal | None, sector_stage: str, trend_penalty: float, ) -> list[str]: tags: list[str] = [] if market.temperature < 45: tags.append("market_weak") if sector_stage in ("late", "end"): tags.append(f"sector_{sector_stage}") if trend_penalty < 0.9: tags.append("trend_under_pressure") if tech: if tech.position_score < 35: tags.append("position_high") if tech.rally_pct_10d > 20: tags.append("short_term_overheat") return tags def _build_penalty_notes( penalties: list[float], trend_penalty: float, theme_penalty: float, market_temp_score: float, sector_stage: str, hot_theme_match: SectorInfo | None, ) -> list[dict]: notes: list[dict] = [] if trend_penalty < 1: notes.append({"label": "趋势压力", "value": f"-{round((1 - trend_penalty) * 100)}%", "reason": "短中期均线偏弱"}) if sector_stage == "end": notes.append({"label": "板块尾声", "value": "最高-30%", "reason": "主题阶段进入尾声"}) elif sector_stage == "late": notes.append({"label": "板块后段", "value": "最高-12%", "reason": "主题阶段偏后"}) if market_temp_score < 30: notes.append({"label": "市场温度偏冷", "value": "最高-25%", "reason": f"温度{market_temp_score:.0f}"}) elif market_temp_score < 50: notes.append({"label": "市场温度一般", "value": "最高-12%", "reason": f"温度{market_temp_score:.0f}"}) if theme_penalty < 1: label = "未匹配主线" if not hot_theme_match else "非前排主线" notes.append({"label": label, "value": f"-{round((1 - theme_penalty) * 100)}%", "reason": "主题地位不足"}) if not notes and penalties: notes.append({"label": "风险折扣", "value": f"-{round((1 - min(penalties)) * 100)}%", "reason": "存在风险项"}) return notes[:4] def _build_decision_trace( stock: dict, score: float, score_weights: dict[str, float], scoring_axes: dict[str, float], flow_momentum_score: float, supply_demand_score: float, price_action_score: float, trend_score: float, capital_score: float, position_score: float, valuation_score: float, entry_signal_type: str, entry_signal_score: float, signal_matches_profile: bool, sector_stage: str, sector_limit_up: int, catalyst_score: float, catalyst_reasons: list[str], market_temp: MarketTemperature, trade_plan: dict, boosts: list[dict], penalties: list[dict], risk_tags: list[str], hot_theme_match: SectorInfo | None, ) -> dict: tags = stock.get("recall_tags", []) or [] headline = _build_decision_headline( stock=stock, action_plan=trade_plan.get("action_plan", "观察"), entry_signal_type=entry_signal_type, hot_theme_match=hot_theme_match, score=score, ) score_breakdown = [ { "key": "catalyst", "label": "新闻催化", "score": round(scoring_axes.get("catalyst", 0), 1), "weight": round(score_weights.get("catalyst", 0), 2), }, { "key": "theme_money", "label": "主题资金", "score": round(scoring_axes.get("theme_money", 0), 1), "weight": round(score_weights.get("theme_money", 0), 2), }, { "key": "stock_money", "label": "个股资金", "score": round(scoring_axes.get("stock_money", 0), 1), "weight": round(score_weights.get("stock_money", 0), 2), }, { "key": "emotion_role", "label": "情绪角色", "score": round(scoring_axes.get("emotion_role", 0), 1), "weight": round(score_weights.get("emotion_role", 0), 2), }, { "key": "timing", "label": "入场时机", "score": round(scoring_axes.get("timing", 0), 1), "weight": round(score_weights.get("timing", 0), 2), }, ] evidence = _build_trace_evidence( tags=tags, main_net=float(stock.get("main_net_inflow", 0) or 0), inflow_ratio=float(stock.get("inflow_ratio", 0) or 0), sector_limit_up=sector_limit_up, entry_signal_type=entry_signal_type, entry_signal_score=entry_signal_score, signal_matches_profile=signal_matches_profile, hot_theme_match=hot_theme_match, catalyst_score=catalyst_score, catalyst_reasons=catalyst_reasons, ) return { "version": 1, "headline": headline, "action_plan": trade_plan.get("action_plan", "观察"), "final_score": round(score, 1), "route_tags": tags, "evidence": evidence, "score_breakdown": score_breakdown, "aux_scores": { "flow_momentum": round(flow_momentum_score, 1), "supply_demand": round(supply_demand_score, 1), "price_action": round(price_action_score, 1), "trend": round(trend_score, 1), "capital": round(capital_score, 1), "position": round(position_score, 1), "valuation": round(valuation_score, 1), }, "context": { "market_temperature": round(market_temp.temperature, 1), "sector_stage": sector_stage, "sector_limit_up": sector_limit_up, "theme_matched": bool(hot_theme_match), "theme_name": hot_theme_match.sector_name if hot_theme_match else "", }, "catalyst": { "score": round(catalyst_score, 1), "reasons": catalyst_reasons[:3], }, "boosts": boosts[:4], "penalties": penalties[:4], "risk_tags": risk_tags, "llm_adjustment": None, } def _build_decision_headline( stock: dict, action_plan: str, entry_signal_type: str, hot_theme_match: SectorInfo | None, score: float, ) -> str: role = stock.get("stock_role_hint") or "候选标的" theme = hot_theme_match.sector_name if hot_theme_match else stock.get("sector", "") signal_label = { "breakout": "突破", "breakout_confirm": "突破确认", "pullback": "回踩", "launch": "启动", "reversal": "反转", "flow_momentum": "资金顺势", "none": "观察", }.get(entry_signal_type, entry_signal_type or "观察") if action_plan == "可操作": prefix = "可操作" elif action_plan == "重点关注": prefix = "重点关注" else: prefix = "观察" theme_part = f"{theme}内" if theme else "" return f"{prefix}: {theme_part}{role},{signal_label}线索,综合分{score:.0f}" def _build_trace_evidence( tags: list[str], main_net: float, inflow_ratio: float, sector_limit_up: int, entry_signal_type: str, entry_signal_score: float, signal_matches_profile: bool, hot_theme_match: SectorInfo | None, catalyst_score: float = 0, catalyst_reasons: list[str] | None = None, ) -> list[str]: evidence: list[str] = [] if hot_theme_match: evidence.append(f"匹配主线主题 {hot_theme_match.sector_name}") if catalyst_score > 0: reason = (catalyst_reasons or [""])[0] suffix = f": {reason}" if reason else "" evidence.append(f"新闻/政策催化分 {catalyst_score:.0f}{suffix}") if tags: evidence.append("召回来源: " + " / ".join(tags[:3])) if main_net > 0: evidence.append(f"主力净流入 {main_net:.0f} 万,占比 {inflow_ratio:.1f}%") if sector_limit_up > 0: evidence.append(f"板块涨停扩散 {sector_limit_up} 家") if entry_signal_type and entry_signal_type != "none": evidence.append(f"入场信号 {entry_signal_type},信号分 {entry_signal_score:.0f}") if signal_matches_profile: evidence.append("符合今日策略偏好的入场类型") return evidence[:5]