2681 lines
103 KiB
Python
2681 lines
103 KiB
Python
"""主题驱动的 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]
|