astock-agent/backend/app/engine/screener.py
2026-06-10 08:36:25 +08:00

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