astock-agent/backend/app/engine/screener.py
2026-05-14 11:10:17 +08:00

2410 lines
93 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""主题驱动的 A 股中短线筛选器。
三阶段管道:
Step 1: 主线定位 — 把实时板块/快照板块归一成系统 MarketTheme
Step 2: 主题内选股 — 从主线主题成分、领涨股和实时异动中召回候选
Step 3: 深度分析 — 资金顺势 + 供需 + 价格行为 + 趋势 + LLM
评分公式:资金顺势 + 供需关系 + 价格行为 + 趋势。
资金流和主题地位进入主评分RSI/MACD 只作为节奏与风险参考。
风险乘数:惩罚取最大而非叠加(防过度惩罚),奖励可叠加。
数据源:
- 盘中模式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
from app.catalyst.service import build_theme_catalyst_scores
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]
hot_sectors = await _apply_catalyst_scores(hot_sectors)
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 _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,
) -> 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 {
"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:
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)
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)
vol_pattern = analyze_volume_pattern(df)
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)
# 收集 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": effective_signal_name,
"entry_signal_score": round(entry_signal.get("signal_score", 0), 1),
"flow_momentum_score": round(flow_momentum_score, 1),
"signal_matches_profile": signal_matches_profile,
"risk_tags": risk_tags,
"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)
_apply_llm_trace(
rec,
verdict=verdict,
action_plan=rec.action_plan,
conviction=conviction,
reason=llm_data.get("analysis", "") or llm_data.get("risk_flag", ""),
)
# 用 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 _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 _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]
def _apply_llm_trace(
rec: Recommendation,
verdict: str,
action_plan: str,
conviction: float,
reason: str,
) -> None:
trace = dict(rec.decision_trace or {})
trace["llm_adjustment"] = {
"verdict": verdict,
"action_plan": action_plan,
"conviction": round(conviction, 1),
"reason": str(reason or "")[:180],
}
trace["action_plan"] = action_plan
if verdict == "execute":
trace["headline"] = f"AI确认可执行: {trace.get('headline', rec.name)}"
elif verdict == "skip":
trace["headline"] = f"AI降级观察: {trace.get('headline', rec.name)}"
rec.decision_trace = trace
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}信号是否延续")
elif stock.get("entry_signal_type") == "flow_momentum":
points.append("确认主力流入和板块前排强度是否延续")
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)