"""主题驱动的 A 股中短线筛选器。 三阶段管道: Step 1: 主线定位 — 把实时板块/快照板块归一成系统 MarketTheme Step 2: 主题内选股 — 从主线主题成分、领涨股和实时异动中召回候选 Step 3: 深度分析 — 供需 + 价格行为 + 趋势 + LLM (10-15只推荐) 评分公式:供需关系 50% + 价格行为 40% + 趋势 10% 主题地位和资金流作为前置上下文,涨停/广度只作为辅助证据。 风险乘数:惩罚取最大而非叠加(防过度惩罚),奖励可叠加。 数据源: - 盘中模式:Tushare 日线 + 腾讯实时行情 + 东方财富5分钟K线 - 盘后模式:Tushare 当日完整数据 止损止盈:基于市场结构(阻力位/支撑MA/近期低点),而非固定百分比。 """ import asyncio import logging import traceback 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 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) -> 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 # ── 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] 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} ===" ) # ── Step 2: 多路召回构建候选池 ── logger.info("=== Step 2: 多路召回候选池 ===") candidates = await _build_candidate_pool( hot_sectors=hot_sectors, trade_date=trade_date, intraday=intraday, market_temp=market_temp, ) if not candidates: logger.info("=== 筛选完成: 0 只股票 ===") return { "market_temp": market_temp, "hot_sectors": hot_sectors, "recommendations": [], "scan_mode": scan_mode, } # ── Step 3 之前:注入腾讯实时价格(防止 Tushare 日线数据过时) ── if candidates: 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 except Exception as e: logger.warning(f"注入实时价格失败,使用 Tushare 收盘价: {e}") # ── Step 3: 规则边界 + LLM 两阶段裁决 ── logger.info("=== Step 3: 规则边界 + LLM 两阶段裁决 ===") recommendations = await _build_recommendations( candidates, market_temp, hot_sectors, market_temp_score, intraday, strategy_profile, ) if settings.deepseek_api_key: recommendations = [ r for r in recommendations if ( _is_main_theme_recommendation(r) and ( r.action_plan in {"可操作", "重点关注"} or (r.llm_score is not None and r.llm_score >= 6) or r.score >= max(strategy_profile.min_score - 4, 56) ) ) ] else: recommendations = [ r for r in recommendations 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": "反转型"} 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 _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, ) -> list[dict]: """多路召回候选池。 目标是提高召回率,再交给 LLM 做资源分配与最终裁决。 """ 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: 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)}" ) 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, ) -> list[Recommendation]: """Step 3: 规则边界建模 + LLM 两阶段裁决。""" 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, analyze_volume_pattern, EntrySignal, ) from app.analysis.signals import generate_signals from app.analysis.capital_flow import _score_valuation from app.llm.batch_screener import ( analyze_candidates_individually, prefilter_candidates_individually, ) # 名称和行业映射 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 = [] llm_candidates = [] total = len(candidates) 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 { "supply_demand": 0.50, "price_action": 0.40, "trend": 0.10, } 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: 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: 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}, 跳过") 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) 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 final_score = ( supply_demand_score * score_weights["supply_demand"] + price_action_score * score_weights["price_action"] + trend_score * score_weights["trend"] ) final_score *= trend_penalty tech_signal = generate_signals(ts_code, name) 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) sector_stage = _get_sector_stage(sector, hot_sectors) hot_theme_match = find_hot_theme_match(sector, hot_sectors) 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) sector_limit_up = _get_sector_limit_up(sector, hot_sectors) if sector_limit_up >= 5: final_score *= 1.20 elif sector_limit_up >= 3: final_score *= 1.10 if entry_signal.get("signal_score", 0) >= 80: final_score *= 1.10 if not hot_theme_match: final_score *= 0.82 elif hot_theme_match not in hot_sectors[:5]: final_score *= 0.9 signal_matches_profile = bool(signal_priority and signal_name in signal_priority[:4]) if signal_type != EntrySignal.NONE and signal_priority: priority_rank = signal_priority.index(signal_type.value) if priority_rank == 0: final_score *= 1.08 elif priority_rank == 1: final_score *= 1.04 elif priority_rank >= 3: final_score *= 0.94 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 if ( signal_type != EntrySignal.NONE and entry_signal.get("signal_score", 0) >= 50 and position_score >= 30 and final_score >= buy_threshold ): 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) reasons = _generate_reasons(stock, entry_signal, tech_signal, df, intraday) stock["entry_signal_type"] = signal_name risk_note = _generate_risk_note(market_temp, tech_signal, stock) vol_pattern = analyze_volume_pattern(df) entry_timing = _generate_entry_timing(signal_name, intraday) trade_plan = _build_trade_plan( signal_type=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, ) 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(_score_capital_simple(stock), 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=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=[], ) recommendations.append(rec) # 收集 LLM 分析所需的候选摘要(不含 signal_type,让 LLM 独立判断) llm_candidate = { "ts_code": ts_code, "name": name, "sector": sector, "quant_score": round(final_score, 1), "position_score": round(position_score, 1), "current_price": stock.get("price") or float(df.iloc[-1]["close"]), "kline_summary": _summarize_for_llm(df, entry_signal, tech_signal), "capital_flow_summary": ( f"主力净流入{stock.get('main_net_inflow', 0):.0f}万, " f"占比{stock.get('inflow_ratio', 0):.1f}%" ), "recall_tags": stock.get("recall_tags", []), "sector_stage": sector_stage, "hot_theme_matched": bool(hot_theme_match), "hot_theme_name": hot_theme_match.sector_name if hot_theme_match else "", "hot_theme_aliases": hot_theme_match.theme_aliases if hot_theme_match else [], "stock_role_hint": stock.get("stock_role_hint", "待判断"), "entry_signal_type": signal_name, "entry_signal_score": round(entry_signal.get("signal_score", 0), 1), "signal_matches_profile": signal_matches_profile, "risk_tags": _build_risk_tags(market_temp, tech_signal, sector_stage, trend_penalty), "focus_points": _build_focus_points(stock, entry_signal, tech_signal, vol_pattern, sector_stage), } if intraday: try: from app.data.eastmoney_client import get_min_kline, analyze_intraday_volume_distribution min_df = await get_min_kline(ts_code, period="5", count=48) if not min_df.empty: vol_dist = analyze_intraday_volume_distribution(min_df) llm_candidate["intraday_volume"] = ( f"上午量占比{vol_dist['morning_volume_ratio']}%, " f"下午{vol_dist['afternoon_volume_ratio']}%, " f"开盘30分{vol_dist['opening_strength']}%, " f"尾盘30分{vol_dist['closing_strength']}%, " f"趋势={vol_dist['volume_trend']}" ) if vol_dist["key_periods"]: llm_candidate["intraday_volume"] += f", 放量时段: {'; '.join(vol_dist['key_periods'])}" except Exception as e: logger.debug(f"分时量能数据获取失败 {ts_code}: {e}") llm_candidates.append(llm_candidate) except Exception as e: logger.debug(f"深度分析 {ts_code} 失败: {e}") 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 settings.deepseek_api_key and llm_candidates: try: market_summary = ( f"市场温度: {market_temp.temperature}/100, " f"涨跌比: {market_temp.up_count}涨/{market_temp.down_count}跌, " f"涨停: {market_temp.limit_up_count}家; " f"今日主线主题: " + " / ".join( f"{s.sector_name}[{' / '.join((s.theme_aliases or [])[:3])}]" f"({(s.realtime_pct_change if s.realtime_pct_change is not None else s.pct_change):+.2f}%)" for s in hot_sectors[:5] ) ) llm_candidates.sort(key=lambda c: c["quant_score"], reverse=True) prefilter_pool = llm_candidates[: settings.llm_prefilter_limit] prefilter_results = await prefilter_candidates_individually( prefilter_pool, market_summary, max_concurrent=settings.llm_prefilter_max_concurrent, ) prioritized = [] for item in prefilter_pool: pre = prefilter_results.get(item["ts_code"], {}) item["prefilter_decision"] = pre.get("decision", "watch") item["prefilter_confidence"] = pre.get("confidence", 5) item["prefilter_reason"] = pre.get("reason", "") item["prefilter_focus_points"] = pre.get("focus_points", []) if item["prefilter_decision"] == "priority": rank_bonus = 16 elif item["prefilter_decision"] == "watch": rank_bonus = 6 else: rank_bonus = -12 item["deep_rank"] = round(item["quant_score"] + rank_bonus + item["prefilter_confidence"] * 1.5, 1) if item["prefilter_decision"] != "ignore": prioritized.append(item) if not prioritized: prioritized = prefilter_pool[: min(8, len(prefilter_pool))] prioritized.sort(key=lambda c: c.get("deep_rank", c["quant_score"]), reverse=True) llm_top = prioritized[: settings.llm_final_limit] llm_results = await analyze_candidates_individually(llm_top, market_summary) for rec in recommendations: pre_item = next((item for item in prefilter_pool if item["ts_code"] == rec.ts_code), None) if pre_item: rec.prefilter_decision = pre_item.get("prefilter_decision", "") rec.prefilter_reason = pre_item.get("prefilter_reason", "") rec.focus_points = pre_item.get("prefilter_focus_points", []) llm_data = llm_results.get(rec.ts_code) if llm_data: rec.llm_analysis = llm_data.get("analysis", "") rec.llm_score = float(llm_data.get("conviction", 0) or 0) verdict = llm_data.get("verdict", "watch") action_plan = llm_data.get("action_plan", "") conviction = float(llm_data.get("conviction", 6) or 6) ai_score = conviction * 10 if verdict == "execute": rec.score = round(rec.score * 0.4 + ai_score * 0.6 + 4, 1) elif verdict == "watch": rec.score = round(rec.score * 0.5 + ai_score * 0.5 - 2, 1) else: # skip rec.score = round(rec.score * 0.45 + ai_score * 0.35 - 18, 1) if verdict == "skip": rec.signal = "HOLD" rec.action_plan = "观察" rec.lifecycle_status = "candidate" if not rec.risk_note: rec.risk_note = llm_data.get("risk_flag", "") or rec.risk_note else: if action_plan in {"可操作", "重点关注", "观察"}: rec.action_plan = action_plan elif verdict == "execute": rec.action_plan = "可操作" else: rec.action_plan = "重点关注" rec.signal = "BUY" if verdict == "execute" else "HOLD" if rec.action_plan == "可操作": rec.lifecycle_status = "actionable" elif rec.action_plan == "重点关注": rec.lifecycle_status = "candidate" if llm_data.get("timing"): rec.entry_timing = llm_data["timing"] if llm_data.get("trigger_condition"): rec.trigger_condition = llm_data["trigger_condition"] if llm_data.get("invalidation_condition"): rec.invalidation_condition = llm_data["invalidation_condition"] if llm_data.get("position_pct") is not None: rec.suggested_position_pct = float(llm_data["position_pct"] or 0) if llm_data.get("risk_flag"): rec.risk_note = llm_data["risk_flag"] rec.level = _score_to_level(rec.score) # 用 LLM 给出的价格替代结构化规则价格 if llm_data.get("entry_price"): rec.entry_price = llm_data["entry_price"] if llm_data.get("target_price"): rec.target_price = llm_data["target_price"] if llm_data.get("stop_loss"): rec.stop_loss = llm_data["stop_loss"] recommendations = [ rec for rec in recommendations if not ( rec.llm_score is not None and rec.llm_score <= 4 and rec.action_plan == "观察" and rec.score < max(strategy_profile.min_score - 6, 54) ) ] recommendations.sort(key=lambda r: r.score, reverse=True) recommendations = recommendations[:settings.top_stock_count] logger.info(f"LLM 两阶段分析完成, 综合评分后保留 {len(recommendations)} 只") except Exception as e: logger.error(f"LLM 两阶段分析失败, 仅使用规则边界: {e}") from app.db.error_logger import log_error await log_error("screener", f"LLM 两阶段分析失败, 仅使用规则边界: {e}", detail=traceback.format_exc()) return recommendations # ── 价格行为评分 ── 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_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确认不回落再进场,避免早盘追高", } 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": "放量反转", }.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 _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 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("反转型可能二次探底,确认底部后再加仓") 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_focus_points( stock: dict, entry_signal: dict, tech: TechnicalSignal | None, vol_pattern: dict, sector_stage: str, ) -> list[str]: points: list[str] = [] signal_type = entry_signal.get("signal_type") if signal_type and getattr(signal_type, "value", "none") != "none": points.append(f"确认{signal_type.value}信号是否延续") if stock.get("main_net_inflow", 0) > 0: points.append("观察主力流入是否继续放大") if vol_pattern.get("volume_trend"): points.append(f"量能状态: {vol_pattern['volume_trend']}") if tech and tech.support_price: points.append(f"关键支撑 {tech.support_price}") if sector_stage in ("late", "end"): points.append("板块已偏后段,注意是否还有前排承接") return points[:4] def _summarize_for_llm(df, entry_signal: dict, tech_signal: TechnicalSignal | None) -> str: """生成 K 线分析结论供 LLM 判断(输出结论而非原始数据)""" import pandas as pd last = df.iloc[-1] parts = [] # ── 趋势结论 ── ma_fields = ["ma5", "ma10", "ma20", "ma60"] ma_vals = {m: last.get(m) for m in ma_fields} trend_desc = "趋势不明" all_ma_valid = all(ma_vals.get(m) is not None and not pd.isna(ma_vals[m]) for m in ma_fields) if all_ma_valid: if ma_vals["ma5"] > ma_vals["ma10"] > ma_vals["ma20"] > ma_vals["ma60"]: trend_desc = "强势多头排列(MA5>MA10>MA20>MA60)" elif ma_vals["ma5"] > ma_vals["ma10"] > ma_vals["ma20"]: trend_desc = "中短期多头(MA5>MA10>MA20)" elif ma_vals["ma5"] > ma_vals["ma20"]: trend_desc = "偏多(MA5在MA20上方)" elif ma_vals["ma5"] < ma_vals["ma10"] < ma_vals["ma20"]: trend_desc = "空头排列,趋势偏弱" else: trend_desc = "均线交织,趋势震荡" # MA20 方向 if len(df) >= 5 and not pd.isna(last.get("ma20")) and not pd.isna(df.iloc[-5].get("ma20")): ma20_now = last["ma20"] ma20_5d = df.iloc[-5]["ma20"] if ma20_5d > 0: ma20_pct = (ma20_now - ma20_5d) / ma20_5d * 100 if ma20_pct > 2: trend_desc += ",MA20快速上扬" elif ma20_pct > 0: trend_desc += ",MA20缓慢上行" else: trend_desc += ",MA20走平或下行" parts.append(trend_desc) # ── 量价结论 ── if len(df) >= 10: recent = df.tail(10) up_days = recent[recent["pct_chg"] > 0] down_days = recent[recent["pct_chg"] <= 0] vol_conclusion = "" 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: ratio = avg_up_vol / avg_down_vol if ratio > 1.5: vol_conclusion = f"量价健康(上涨均量/下跌均量={ratio:.1f},需求主导)" elif ratio > 1.0: vol_conclusion = f"量价尚可(量比={ratio:.1f},需求略强)" else: vol_conclusion = f"量价偏弱(量比={ratio:.1f},供给主导)" if not vol_conclusion: vol_conclusion = "量价数据不足" # 最近5日量能变化 recent_5 = df.tail(5) vol_ma5 = recent_5["vol"].mean() vol_ma10 = df.tail(10)["vol"].mean() if vol_ma10 > 0: vol_ratio = vol_ma5 / vol_ma10 if vol_ratio > 1.5: vol_conclusion += ",近5日明显放量" elif vol_ratio < 0.7: vol_conclusion += ",近5日缩量" parts.append(vol_conclusion) # ── MACD 结论 ── dif = last.get("dif", 0) or 0 dea = last.get("dea", 0) or 0 macd_desc = "" if len(df) >= 3: prev_dif = df.iloc[-2].get("dif", 0) or 0 prev_dea = df.iloc[-2].get("dea", 0) or 0 if dif > dea and prev_dif <= prev_dea: macd_desc = "MACD刚金叉" elif dif > dea: macd_desc = "MACD金叉运行中" elif dif < dea and prev_dif >= prev_dea: macd_desc = "MACD刚死叉" elif dif < dea: macd_desc = "MACD死叉运行中" if dif > 0: macd_desc += ",零轴上方(偏多)" else: macd_desc += ",零轴下方(偏空)" parts.append(macd_desc or "MACD数据不足") # ── RSI 结论 ── rsi = last.get("rsi14", 50) if not pd.isna(rsi): if rsi > 80: parts.append(f"RSI14={rsi:.0f},超买区,回调风险大") elif rsi > 70: parts.append(f"RSI14={rsi:.0f},偏高,注意追高风险") elif rsi >= 40: parts.append(f"RSI14={rsi:.0f},健康区间") else: parts.append(f"RSI14={rsi:.0f},偏低,可能超卖") # ── 价格位置结论 ── if tech_signal: pos_parts = [] if tech_signal.rally_pct_5d > 15: pos_parts.append(f"5日已涨{tech_signal.rally_pct_5d}%,追高风险大") elif tech_signal.rally_pct_5d > 8: pos_parts.append(f"5日涨{tech_signal.rally_pct_5d}%,短期有一定涨幅") elif tech_signal.rally_pct_5d > 0: pos_parts.append(f"5日涨{tech_signal.rally_pct_5d}%,涨幅温和") else: pos_parts.append(f"5日跌{abs(tech_signal.rally_pct_5d)}%,回调中") if tech_signal.distance_from_high >= 0: pos_parts.append("处于60日新高附近") elif tech_signal.distance_from_high > -5: pos_parts.append(f"距60日高点{abs(tech_signal.distance_from_high):.1f}%") else: pos_parts.append(f"距60日高点{abs(tech_signal.distance_from_high):.1f}%,位置较低") parts.append("位置: " + ",".join(pos_parts)) # ── 近5日价格走势简述 ── if len(df) >= 5: recent_5 = df.tail(5) closes = recent_5["close"].tolist() first_c = closes[0] last_c = closes[-1] pct_5d = (last_c - first_c) / first_c * 100 parts.append(f"当前价: {last_c:.2f},5日{'涨' if pct_5d >= 0 else '跌'}{abs(pct_5d):.1f}%") return "\n".join(parts)