astock-agent/backend/app/engine/screener.py
2026-04-16 14:16:02 +08:00

1211 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""趋势突破统一筛选器(自上而下方案,中短线交易定位)
三阶段管道:
Step 1: 板块定位 — 找到有资金流入的热门板块 (3-5个)
Step 2: 板块内选股 — 在热门板块成分股中筛出有资金流入的候选 (30-50只)
Step 3: 深度分析 — 供需 + 价格行为 + 趋势 + LLM (10-15只推荐)
评分公式:供需关系 50% + 价格行为 40% + 趋势 10%
板块和资金流作为前置过滤条件,板块涨停数作为情绪奖励。
风险乘数:惩罚取最大而非叠加(防过度惩罚),奖励可叠加。
数据源:
- 盘中模式Tushare 日线 + 腾讯实时行情 + 东方财富5分钟K线
- 盘后模式Tushare 当日完整数据
止损止盈:基于市场结构(阻力位/支撑MA/近期低点),而非固定百分比。
"""
import logging
import pandas as pd
from app.analysis.market_temp import calculate_market_temperature
from app.analysis.sector_scanner import scan_hot_sectors
from app.analysis.trend_scanner import scan_trend_breakout
from app.analysis.signals import generate_signals
from app.analysis.intraday import intraday_market_temperature, intraday_filter_stocks, intraday_sector_scan
from app.data.models import MarketTemperature, SectorInfo, TechnicalSignal, Recommendation
from app.config import settings, is_trading_hours, is_market_session
logger = logging.getLogger(__name__)
async def run_screening(trade_date: str = None) -> dict:
"""执行趋势突破筛选流程
返回: {
"market_temp": MarketTemperature,
"hot_sectors": [SectorInfo],
"recommendations": [Recommendation],
"scan_mode": "intraday" | "post_market",
}
"""
intraday = is_market_session()
scan_mode = "intraday" if intraday else "post_market"
logger.info(f"=== 筛选模式: {'盘中实时' if intraday else '盘后'} ===")
# ── 市场温度 ──
logger.info("=== 市场温度计 ===")
market_temp = calculate_market_temperature(trade_date)
if intraday:
market_temp = await intraday_market_temperature(market_temp)
logger.info(f"盘中市场温度(实时调整): {market_temp.temperature}")
else:
logger.info(f"市场温度: {market_temp.temperature}")
market_temp_score = market_temp.temperature
# ── Step 1: 板块定位 ──
logger.info("=== Step 1: 板块定位 ===")
all_sectors = scan_hot_sectors(trade_date)
# 前置过滤:只保留有资金流入 + 非末期的板块
hot_sectors = [
s for s in all_sectors
if s.capital_inflow > 0 and s.stage not in ("end",)
][:settings.top_sector_count]
if not hot_sectors:
logger.info("无合格热门板块(需要资金流入+非末期),回退到全部板块")
hot_sectors = all_sectors[:settings.top_sector_count]
for s in hot_sectors:
logger.info(f" 目标板块: {s.sector_name} 涨幅{s.pct_change}% 资金{s.capital_inflow:.0f}"
f"涨停{s.limit_up_count} 阶段={s.stage}")
# 盘中用实时行情更新板块涨幅和涨停数
if intraday:
hot_sectors = await intraday_sector_scan(hot_sectors)
# ── Step 2: 板块内选股 ──
logger.info("=== Step 2: 板块内选股 ===")
if intraday:
candidates = await intraday_filter_stocks(hot_sectors)
else:
candidates = await _select_from_hot_sectors(hot_sectors, trade_date, intraday)
if not candidates:
logger.info("=== Step 2 无候选,回退到全市场扫描 ===")
candidates = await scan_trend_breakout(
trade_date=trade_date,
market_temp=market_temp,
hot_sectors=hot_sectors,
intraday=intraday,
)
if not candidates:
logger.info("=== 筛选完成: 0 只股票 ===")
return {
"market_temp": market_temp,
"hot_sectors": hot_sectors,
"recommendations": [],
"scan_mode": scan_mode,
}
# ── Step 3 之前:注入腾讯实时价格(防止 Tushare 日线数据过时) ──
if candidates:
try:
from app.data.tencent_client import get_realtime_quotes_batch
codes = [c["ts_code"] for c in candidates if "ts_code" in c]
quotes = await get_realtime_quotes_batch(codes)
for c in candidates:
q = quotes.get(c["ts_code"])
if q and q.price > 0:
c["price"] = q.price
except Exception as e:
logger.warning(f"注入实时价格失败,使用 Tushare 收盘价: {e}")
# ── Step 3: 供需 + 价格行为 + 趋势评分 ──
logger.info("=== Step 3: 深度分析 ===")
recommendations = await _build_recommendations(
candidates, market_temp, hot_sectors, market_temp_score, intraday,
)
# 过滤低质量推荐低于60分不推荐
recommendations = [r for r in recommendations if r.score >= 60]
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,
}
async def _select_from_hot_sectors(
hot_sectors: list[SectorInfo],
trade_date: str,
intraday: bool,
) -> list[dict]:
"""Step 2: 从热门板块成分股中选出有资金流入的候选
流程:
1. 收集所有热门板块的成分股代码
2. 用 get_daily_all + get_daily_basic 过滤市值/换手率
3. 用 get_moneyflow_batch 过滤主力净流入 > 0
4. 对候选做入场信号初筛(只需满足任一信号类型)
"""
from app.data.tushare_client import tushare_client
from datetime import datetime, timedelta
import pandas as pd
if not trade_date:
trade_date = tushare_client.get_latest_trade_date()
# 收集热门板块成分股代码
sector_member_codes: set[str] = set()
sector_code_map: dict[str, str] = {} # ts_code -> sector_name
for s in hot_sectors:
try:
members_df = tushare_client.get_ths_members(s.sector_code)
if not members_df.empty and "con_code" in members_df.columns:
codes = members_df["con_code"].tolist()
sector_member_codes.update(codes)
for c in codes:
sector_code_map[c] = s.sector_name
except Exception as e:
logger.warning(f"获取板块 {s.sector_name} 成分股失败: {e}")
if not sector_member_codes:
logger.info("Step 2: 无板块成分股数据")
return []
logger.info(f"Step 2: 热门板块共 {len(sector_member_codes)} 只成分股")
# 过滤市值/换手率/ST/次新
stock_basic = tushare_client.get_stock_basic()
exclude_codes = set()
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)
# 行业映射
industry_map = {}
if not stock_basic.empty:
for _, row in stock_basic.iterrows():
industry_map[row["ts_code"]] = row.get("industry", "")
# 用 daily_basic 过滤
basic = tushare_client.get_daily_basic(trade_date)
if basic.empty:
logger.info("Step 2: daily_basic 无数据")
return []
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"] >= settings.min_turnover_rate) &
(basic["turnover_rate"] <= settings.max_turnover_rate)
].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 []
# 资金流过滤:主力净流入 > 0
mf = tushare_client.get_moneyflow_batch(trade_date)
if mf.empty:
logger.info("Step 2: 资金流数据为空,跳过资金过滤")
candidate_codes = set(filtered_basic["ts_code"].tolist())
else:
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)
mf_positive = mf[
(mf["ts_code"].isin(set(filtered_basic["ts_code"]))) &
(mf["main_net_inflow"] > 0)
].sort_values("main_net_inflow", ascending=False)
candidate_codes = set(mf_positive["ts_code"].tolist())
# 构建资金流查找表
mf_lookup = {}
for _, row in mf_positive.iterrows():
mf_lookup[row["ts_code"]] = {
"main_net_inflow": float(row["main_net_inflow"]),
"inflow_ratio": float(row.get("inflow_ratio", 0)),
}
logger.info(f"Step 2 资金流过滤: → {len(candidate_codes)} 只主力净流入 > 0")
if not candidate_codes:
return []
# 构建候选列表
import numpy as np
candidates = []
for ts_code in candidate_codes:
name = ""
if not stock_basic.empty:
row = stock_basic[stock_basic["ts_code"] == ts_code]
if not row.empty:
name = row.iloc[0]["name"]
sector_name = sector_code_map.get(ts_code, industry_map.get(ts_code, ""))
b_row = filtered_basic[filtered_basic["ts_code"] == ts_code]
turnover_rate = float(b_row.iloc[0]["turnover_rate"]) if not b_row.empty else 0
circ_mv = float(b_row.iloc[0]["circ_mv"]) if not b_row.empty else 0
pe = float(b_row.iloc[0]["pe"]) if not b_row.empty and pd.notna(b_row.iloc[0].get("pe")) else None
pb = float(b_row.iloc[0]["pb"]) if not b_row.empty and pd.notna(b_row.iloc[0].get("pb")) else None
volume_ratio = float(b_row.iloc[0]["volume_ratio"]) if not b_row.empty and pd.notna(b_row.iloc[0].get("volume_ratio")) else None
try:
mf_info = mf_lookup.get(ts_code, {})
except NameError:
mf_info = {}
candidates.append({
"ts_code": ts_code,
"name": name,
"sector": sector_name,
"turnover_rate": turnover_rate,
"circ_mv": circ_mv,
"pe": pe,
"pb": pb,
"volume_ratio": volume_ratio,
"main_net_inflow": mf_info.get("main_net_inflow", 0),
"inflow_ratio": mf_info.get("inflow_ratio", 0),
})
logger.info(f"Step 2 候选: {len(candidates)}")
return candidates
async def _build_recommendations(
candidates: list[dict],
market_temp: MarketTemperature,
hot_sectors: list[SectorInfo],
market_temp_score: float = 0,
intraday: bool = False,
) -> list[Recommendation]:
"""Step 3: 对候选做供需 + 价格行为 + 趋势深度分析
评分公式:供需关系 40% + 价格行为 35% + 趋势 25%
板块和资金流已在前置过滤中处理。
"""
from app.data.tushare_client import tushare_client
from app.analysis.technical import add_all_indicators
from app.analysis.breakout_signals import (
classify_entry_signal,
score_supply_demand,
analyze_volume_pattern,
EntrySignal,
)
from app.analysis.signals import generate_signals
from app.analysis.capital_flow import _score_valuation
# 名称和行业映射
stock_basic = tushare_client.get_stock_basic()
name_map = {}
industry_map = {}
if not stock_basic.empty:
for _, row in stock_basic.iterrows():
name_map[row["ts_code"]] = row["name"]
industry_map[row["ts_code"]] = row.get("industry", "")
recommendations = []
llm_candidates = [] # 收集候选摘要供 LLM 分析
total = len(candidates)
signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0}
for idx, stock in enumerate(candidates):
ts_code = stock.get("ts_code", "")
if not ts_code:
continue
name = stock.get("name") or name_map.get(ts_code, ts_code)
sector = stock.get("sector") or industry_map.get(ts_code, "")
try:
# 获取 120 日 K 线
df = tushare_client.get_stock_daily(ts_code, 120)
if df.empty or len(df) < 30:
continue
# 数据新鲜度校验:最后一行必须是近 10 天内的数据
from datetime import datetime, timedelta
last_date = str(df.iloc[-1]["trade_date"])
cutoff = (datetime.now() - timedelta(days=10)).strftime("%Y%m%d")
if last_date < cutoff:
logger.warning(f"K线数据过时 {ts_code}: 最新={last_date}, 需≥{cutoff}, 跳过")
continue
# 添加技术指标
df = add_all_indicators(df)
# ── 入场信号分类 ──
entry_signal = classify_entry_signal(df)
signal_type = entry_signal["signal_type"]
if signal_type == EntrySignal.NONE:
signal_counts["none"] += 1
continue
signal_counts[signal_type.value] += 1
# ── 三维度评分 ──
# 1. 供需关系评分 (50%) — 短线核心
supply_demand_score = score_supply_demand(df)
# 2. 价格行为评分 (40%) — 形态质量
price_action_score = _score_price_action(df, entry_signal)
# 3. 趋势评分 (10%) — 短线趋势权重低,偏空直接过滤
trend_score = _score_trend(df)
# 趋势偏空门槛过滤MA5<MA10<MA20空头排列直接跳过
last = df.iloc[-1]
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"]:
signal_counts["none"] += 1
continue
# 综合评分(短线交易:供需最关键,趋势只做门槛)
final_score = (
supply_demand_score * 0.50 +
price_action_score * 0.40 +
trend_score * 0.10
)
# ── 风险乘数:惩罚取最大而非叠加(避免过度惩罚),奖励可叠加 ──
tech_signal = generate_signals(ts_code, name)
# 收集所有惩罚因子(取最大,而非叠加)
penalties = []
if tech_signal:
if tech_signal.rally_pct_5d > 20:
penalties.append(0.65)
elif tech_signal.rally_pct_5d > 15:
penalties.append(0.80)
sector_stage = _get_sector_stage(sector, hot_sectors)
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)
# 取最大惩罚1.0 = 无惩罚)
if penalties:
final_score *= min(penalties)
# 奖励可叠加(奖励之间互不矛盾)
sector_limit_up = _get_sector_limit_up(sector, hot_sectors)
sector_member_count = _get_sector_member_count(sector, hot_sectors)
if sector_limit_up >= 5:
final_score *= 1.20 # 板块5+涨停,情绪极强
elif sector_limit_up >= 3:
final_score *= 1.10 # 板块3涨停情绪较强
if entry_signal.get("signal_score", 0) >= 80:
final_score *= 1.10
# 估值评分(辅助参考,不参与主评分)
pe = stock.get("pe")
pb = stock.get("pb")
valuation_score = _score_valuation(pe, pb)
# 确定信号和等级
level = _score_to_level(final_score)
signal = "HOLD"
position_score = tech_signal.position_score if tech_signal else 50
if (signal_type != EntrySignal.NONE
and entry_signal.get("signal_score", 0) >= 50
and position_score >= 30
and final_score >= 60):
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":
# 突破型止损在突破点被突破的阻力位下方1%
resistance = details.get("resistance_price", 0)
if resistance and resistance > 0:
stop_loss = round(resistance * 0.99, 2)
else:
# fallback: 近20日低点下方1%
low_20 = float(df.tail(20)["low"].min())
stop_loss = round(low_20 * 0.99, 2)
elif st == "pullback":
# 回踩型止损在支撑均线下方1.5%
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":
# 反转型止损在近5日最低点下方1%
low_5 = float(df.tail(5)["low"].min())
stop_loss = round(low_5 * 0.99, 2)
elif st == "launch":
# 启动型止损在MA20下方2%
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:
# breakout_confirm / 其他近20日低点下方1%
low_20 = float(df.tail(20)["low"].min())
stop_loss = round(min(low_20 * 0.99, current_close * 0.97), 2)
# ── 止盈价:基于下一个阻力位 ──
# 近20日高点作为第一阻力
high_20 = float(df.tail(20)["high"].max())
# 近60日高点作为第二阻力
high_60 = float(df.tail(60)["high"].max()) if len(df) >= 60 else high_20
if st == "breakout":
# 突破型刚突破20日高点目标看60日高点附近
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":
# 启动型:整理后启动,目标看整理区间上方+8%
target_price = round(min(high_20 * 1.03, entry_price * 1.08), 2)
elif st == "reversal":
# 反转型从低位反转目标看近20日高点
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:
# breakout_confirm / 其他
target_price = round(min(high_20 * 0.98, entry_price * 1.05), 2)
# 保底:止损不超过入场价-8%(防止结构化止损太远)
max_stop_pct = 0.08
if stop_loss < entry_price * (1 - max_stop_pct):
stop_loss = round(entry_price * (1 - max_stop_pct), 2)
# 止损不低于入场价-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)
# 保底:止盈不低于入场价+3%(空间太小不值得做)
min_target_pct = 0.03
if target_price < entry_price * (1 + min_target_pct):
target_price = round(entry_price * (1 + min_target_pct), 2)
# 生成推荐理由
reasons = _generate_reasons(stock, entry_signal, tech_signal, df, intraday)
risk_note = _generate_risk_note(market_temp, tech_signal, stock)
# 量价模式
vol_pattern = analyze_volume_pattern(df)
# 进场时机建议(盘中适用)
entry_timing = _generate_entry_timing(signal_type.value, intraday)
rec = Recommendation(
ts_code=ts_code,
name=name,
sector=sector,
score=round(final_score, 1),
market_temp_score=round(market_temp_score, 1),
sector_score=round(_get_sector_heat(sector, hot_sectors), 1),
capital_score=round(_score_capital_simple(stock), 1),
technical_score=round(trend_score, 1),
supply_demand_score=round(supply_demand_score, 1),
price_action_score=round(price_action_score, 1),
position_score=round(position_score, 1),
valuation_score=round(valuation_score, 1),
signal=signal,
entry_price=entry_price,
target_price=target_price,
stop_loss=stop_loss,
reasons=reasons,
risk_note=risk_note,
level=level,
strategy="trend_breakout",
entry_signal_type=signal_type.value,
entry_timing=entry_timing,
)
recommendations.append(rec)
# 收集 LLM 分析所需的候选摘要(不含 signal_type让 LLM 独立判断)
llm_candidate = {
"ts_code": ts_code,
"name": name,
"sector": sector,
"quant_score": round(final_score, 1),
"position_score": round(position_score, 1),
"current_price": stock.get("price") or float(df.iloc[-1]["close"]),
"kline_summary": _summarize_for_llm(df, entry_signal, tech_signal),
"capital_flow_summary": (
f"主力净流入{stock.get('main_net_inflow', 0):.0f}万, "
f"占比{stock.get('inflow_ratio', 0):.1f}%"
),
}
# 盘中模式:补充分时量能分布数据
if intraday:
try:
from app.data.eastmoney_client import get_min_kline, analyze_intraday_volume_distribution
min_df = await get_min_kline(ts_code, period="5", count=48)
if not min_df.empty:
vol_dist = analyze_intraday_volume_distribution(min_df)
llm_candidate["intraday_volume"] = (
f"上午量占比{vol_dist['morning_volume_ratio']}%, "
f"下午{vol_dist['afternoon_volume_ratio']}%, "
f"开盘30分{vol_dist['opening_strength']}%, "
f"尾盘30分{vol_dist['closing_strength']}%, "
f"趋势={vol_dist['volume_trend']}"
)
if vol_dist["key_periods"]:
llm_candidate["intraday_volume"] += f", 放量时段: {'; '.join(vol_dist['key_periods'])}"
except Exception as e:
logger.debug(f"分时量能数据获取失败 {ts_code}: {e}")
llm_candidates.append(llm_candidate)
except Exception as e:
logger.debug(f"深度分析 {ts_code} 失败: {e}")
continue
# 让出控制权(同步函数中无法 await跳过
# idx % 10 == 0 的让步在 _select_from_hot_sectors 的上层 async 函数中处理
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}只)"
)
# ── LLM 逐股深度分析 ──
if settings.deepseek_api_key and llm_candidates:
try:
from app.llm.batch_screener import analyze_candidates_individually
# 只对量化评分 Top N 做LLM分析减少API调用
llm_candidates.sort(key=lambda c: c["quant_score"], reverse=True)
llm_top = llm_candidates[:settings.top_stock_count]
market_summary = (
f"市场温度: {market_temp.temperature}/100, "
f"涨跌比: {market_temp.up_count}涨/{market_temp.down_count}跌, "
f"涨停: {market_temp.limit_up_count}"
)
llm_results = await analyze_candidates_individually(llm_top, market_summary)
# 综合量化 + LLM 判断
for rec in recommendations:
llm_data = llm_results.get(rec.ts_code)
if llm_data:
rec.llm_analysis = llm_data.get("analysis", "")
# LLM 信号强度转换为分数调整
strength = llm_data.get("strength", "")
llm_signal = llm_data.get("signal", "HOLD")
# 基础调整:量化评分 * 调整系数
if llm_signal == "SKIP":
adjustment = 0.50
elif llm_signal == "HOLD":
adjustment = 0.85
elif llm_signal == "BUY" and strength == "":
adjustment = 1.15
elif llm_signal == "BUY" and strength == "":
adjustment = 1.05
else: # BUY + 弱
adjustment = 1.00
rec.score = round(rec.score * adjustment, 1)
rec.level = _score_to_level(rec.score)
# 用 LLM 给出的价格替代硬编码价格
if llm_data.get("entry_price"):
rec.entry_price = llm_data["entry_price"]
if llm_data.get("target_price"):
rec.target_price = llm_data["target_price"]
if llm_data.get("stop_loss"):
rec.stop_loss = llm_data["stop_loss"]
recommendations.sort(key=lambda r: r.score, reverse=True)
recommendations = recommendations[:settings.top_stock_count]
logger.info(f"LLM 逐股分析完成, 综合评分后保留 {len(recommendations)}")
except Exception as e:
logger.error(f"LLM 逐股分析失败, 仅使用量化评分: {e}")
return recommendations
# ── 价格行为评分 ──
def _score_price_action(df, entry_signal: dict) -> float:
"""价格行为学评分 (0-100)
纯粹关注 K 线形态和量价配合,不重复评估趋势/均线因素。
维度:
- K线形态强度 (35): 实体占比、收盘位置、下影线
- 量价配合 (35): 放量/缩量与价格方向的配合度
- 入场形态质量 (30): 各信号类型的形态完成度
"""
import pandas as pd
score = 0
last = df.iloc[-1]
details = entry_signal.get("details", {})
signal_type = entry_signal.get("signal_type")
# K线形态强度 (35)
day_range = last["high"] - last["low"]
if day_range > 0:
# 实体占比(实体/全振幅)
body = abs(last["close"] - last["open"])
body_ratio = body / day_range
if body_ratio > 0.7:
score += 20 # 大实体,方向明确
elif body_ratio > 0.4:
score += 12
elif body_ratio > 0.2:
score += 6
# 收盘位置(越接近高点越好)
close_position = (last["close"] - last["low"]) / day_range
if close_position > 0.8:
score += 10 # 收在上部 20%
elif close_position > 0.6:
score += 6
elif close_position > 0.4:
score += 3
# 下影线(回踩型/启动型利好)
lower_wick = (last["open"] - last["low"]) if last["close"] > last["open"] else (last["close"] - last["low"])
if lower_wick > 0:
wick_ratio = lower_wick / day_range
if signal_type and signal_type.value in ("pullback", "reversal") and wick_ratio > 0.2:
score += 5 # 回踩型/反转型有下影线支撑
# 量价配合 (35)
vol_ma_col = "vol_ma5" if "vol_ma5" in df.columns else None
if vol_ma_col and not pd.isna(last[vol_ma_col]) and last[vol_ma_col] > 0:
vol_ratio = last["vol"] / last[vol_ma_col]
price_up = last["pct_chg"] > 0 if "pct_chg" in df.columns else last["close"] > last["open"]
if price_up and vol_ratio > 2.0:
score += 35 # 放量大阳
elif price_up and vol_ratio > 1.5:
score += 25
elif price_up and vol_ratio > 1.2:
score += 18
elif not price_up and vol_ratio < 0.7:
score += 25 # 缩量回调(良性)
elif not price_up and vol_ratio < 0.9:
score += 15
elif price_up and vol_ratio > 1.0:
score += 10
else:
score += 10
# 入场形态质量 (30) — 只评估形态完成度,不涉及均线/MACD
if signal_type and signal_type.value == "breakout":
breakout_pct = details.get("breakout_pct", 0)
vol_ratio = details.get("volume_ratio", 1)
if breakout_pct > 2 and vol_ratio > 2:
score += 30
elif breakout_pct > 1 and vol_ratio > 1.5:
score += 20
elif breakout_pct > 0:
score += 12
else:
score += 6
elif signal_type and signal_type.value == "breakout_confirm":
vol_ratio = details.get("volume_ratio", 1)
confirm_pct = details.get("confirm_pct", 0)
if vol_ratio > 2 and confirm_pct > 2:
score += 30
elif vol_ratio > 1.5 and confirm_pct > 1:
score += 22
elif vol_ratio > 1.0:
score += 14
else:
score += 8
elif signal_type and signal_type.value == "pullback":
support_ma = details.get("support_ma", "")
shrink = details.get("volume_shrink_ratio", 1)
if support_ma == "MA20" and shrink < 0.6:
score += 30
elif support_ma == "MA20":
score += 22
elif support_ma == "MA10" and shrink < 0.6:
score += 18
else:
score += 10
elif signal_type and signal_type.value == "launch":
range_pct = details.get("price_range_pct", 10)
if range_pct < 3:
score += 30
elif range_pct < 5:
score += 20
else:
score += 10
elif signal_type and signal_type.value == "reversal":
reversal_pct = details.get("reversal_pct", 0)
vol_ratio = details.get("volume_ratio", 1)
if reversal_pct > 5 and vol_ratio > 2.5:
score += 30
elif reversal_pct > 3 and vol_ratio > 2:
score += 22
elif reversal_pct > 3:
score += 14
else:
score += 8
else:
score += 10
return min(score, 100)
# ── 趋势评分 ──
def _score_trend(df) -> float:
"""趋势评分 (0-100)
维度:
- 均线排列 (40): MA5>MA10>MA20>MA60
- 更高高点/更高低点结构 (35): 近 20 日价格结构
- MA20 方向 (25): MA20 是否持续上行
"""
import pandas as pd
score = 0
last = df.iloc[-1]
# 均线排列 (40)
ma_cols = [c for c in ["ma5", "ma10", "ma20", "ma60"] if c in df.columns]
if len(ma_cols) >= 4 and not any(pd.isna(last[c]) for c in ma_cols):
if last["ma5"] > last["ma10"] > last["ma20"] > last["ma60"]:
score += 40 # 完美多头
elif last["ma5"] > last["ma10"] > last["ma20"]:
score += 28
elif last["ma5"] > last["ma20"]:
score += 15
elif "ma5" in df.columns and "ma20" in df.columns:
if not pd.isna(last["ma5"]) and not pd.isna(last["ma20"]) and last["ma5"] > last["ma20"]:
score += 15
# 更高高点/更高低点结构 (35)
if len(df) >= 20:
recent = df.tail(20)
# 检查高点抬升
first_10_high = recent["high"].iloc[:10].max()
second_10_high = recent["high"].iloc[10:].max()
# 检查低点抬升
first_10_low = recent["low"].iloc[:10].min()
second_10_low = recent["low"].iloc[10:].min()
if second_10_high > first_10_high and second_10_low > first_10_low:
score += 35 # 既抬高点又抬低点,最健康
elif second_10_high > first_10_high:
score += 20 # 至少高点抬升
elif second_10_low > first_10_low:
score += 12 # 至少低点抬升
# MA20 方向 (25)
if "ma20" in df.columns and len(df) >= 5:
ma20_now = last["ma20"]
ma20_5d = df.iloc[-5]["ma20"]
if not pd.isna(ma20_now) and not pd.isna(ma20_5d) and ma20_5d > 0:
ma20_pct = (ma20_now - ma20_5d) / ma20_5d * 100
if ma20_pct > 2:
score += 25
elif ma20_pct > 1:
score += 18
elif ma20_pct > 0:
score += 10
return min(score, 100)
# ── 辅助函数 ──
def _get_sector_stage(sector_name: str, hot_sectors: list[SectorInfo]) -> str:
"""获取板块所处阶段"""
for s in hot_sectors:
if s.sector_name == sector_name:
return s.stage
return "mid"
def _get_sector_heat(sector_name: str, hot_sectors: list[SectorInfo]) -> float:
"""获取板块热度得分"""
for s in hot_sectors:
if s.sector_name == sector_name:
return s.heat_score
return 30.0
def _get_sector_limit_up(sector_name: str, hot_sectors: list[SectorInfo]) -> int:
"""获取板块涨停数"""
for s in hot_sectors:
if s.sector_name == sector_name:
return s.limit_up_count
return 0
def _get_sector_member_count(sector_name: str, hot_sectors: list[SectorInfo]) -> int:
"""获取板块成分股数量"""
for s in hot_sectors:
if s.sector_name == sector_name:
return s.member_count
return 0
def _score_capital_simple(stock: dict) -> float:
"""资金流简单评分(仅基于已有数据,不额外调 API"""
main_net = stock.get("main_net_inflow", 0) or 0
inflow_ratio = stock.get("inflow_ratio", 0) or 0
score = 0
if main_net > 10000:
score += 60
elif main_net > 5000:
score += 45
elif main_net > 2000:
score += 30
elif main_net > 0:
score += 15
if inflow_ratio > 15:
score += 40
elif inflow_ratio > 10:
score += 30
elif inflow_ratio > 5:
score += 20
elif inflow_ratio > 0:
score += 10
return min(score, 100)
def _generate_entry_timing(signal_type: str, intraday: bool) -> str:
"""根据信号类型生成进场时机建议"""
if not intraday:
return "" # 盘后模式不需要时机建议
timing_map = {
"breakout": "开盘观察是否站稳突破位午后14:00确认不回落再进场",
"breakout_confirm": "突破已确认,盘中放量时可直接进场",
"pullback": "盘中靠近支撑位时分批进场尾盘14:30确认支撑有效可加仓",
"launch": "早盘放量确认后即可进场注意开盘9:30-10:00量能",
"reversal": "午后13:30确认不回落再进场避免早盘追高",
}
return timing_map.get(signal_type, "盘中观察量价配合,确认信号后进场")
def _score_to_level(score: float) -> str:
if score >= 80:
return "强烈推荐"
elif score >= 60:
return "推荐"
elif score >= 40:
return "观望"
else:
return "回避"
def _generate_reasons(
stock: dict, entry_signal: dict, tech: TechnicalSignal | None,
df, intraday: bool = False,
) -> list[str]:
"""生成推荐理由"""
import pandas as pd
from app.analysis.breakout_signals import EntrySignal
reasons = []
signal_type = entry_signal.get("signal_type")
details = entry_signal.get("details", {})
signal_map = {EntrySignal.BREAKOUT: "突破型", EntrySignal.BREAKOUT_CONFIRM: "确认型",
EntrySignal.PULLBACK: "回踩型", EntrySignal.LAUNCH: "启动型",
EntrySignal.REVERSAL: "反转型"}
entry_label = signal_map.get(signal_type, "")
# 入场信号
if entry_label and signal_type:
st = signal_type.value
if st == "breakout":
breakout_pct = details.get("breakout_pct", 0)
vol_ratio = details.get("volume_ratio", 0)
reasons.append(f"放量突破20日阻力位涨幅{breakout_pct:.1f}%,量比{vol_ratio:.1f}倍)")
elif st == "breakout_confirm":
vol_ratio = details.get("volume_ratio", 0)
confirm_pct = details.get("confirm_pct", 0)
reasons.append(f"突破后放量确认(确认日涨{confirm_pct:.1f}%,量比{vol_ratio:.1f}倍)")
elif st == "pullback":
support = details.get("support_ma", "")
shrink = details.get("volume_shrink_ratio", 0)
reasons.append(f"缩量回踩{support}支撑(量能收缩至{shrink:.0%}")
elif st == "launch":
range_pct = details.get("price_range_pct", 0)
reasons.append(f"缩量横盘整理{range_pct:.1f}%后首日放量启动")
elif st == "reversal":
reversal_pct = details.get("reversal_pct", 0)
vol_ratio = details.get("volume_ratio", 0)
reasons.append(f"连续下跌后放量长阳反转(涨{reversal_pct:.1f}%,量比{vol_ratio:.1f}倍)")
# 供需分析
if len(df) >= 10:
recent = df.tail(10)
up_days = recent[recent["pct_chg"] > 0]
down_days = recent[recent["pct_chg"] <= 0]
if len(up_days) > 0 and len(down_days) > 0:
avg_up_vol = up_days["vol"].mean()
avg_down_vol = down_days["vol"].mean()
if avg_down_vol > 0:
ds_ratio = avg_up_vol / avg_down_vol
if ds_ratio > 1.5:
reasons.append(f"需求主导(上涨均量/下跌均量={ds_ratio:.1f}")
# 资金流
main_net = stock.get("main_net_inflow", 0)
if main_net > 5000:
reasons.append(f"主力资金大幅流入{main_net:.0f}万元")
elif main_net > 1000:
reasons.append(f"主力资金持续流入{main_net:.0f}万元")
# 板块
sector = stock.get("sector", "")
if sector:
reasons.append(f"所属热门板块【{sector}")
return reasons[:3]
def _generate_risk_note(
market: MarketTemperature,
tech: TechnicalSignal | None,
stock: dict,
) -> str:
"""生成风险提示"""
notes = []
entry_type = stock.get("entry_signal_type", "")
if entry_type == "breakout":
notes.append("突破型需警惕假突破,关注量能是否持续")
elif entry_type == "breakout_confirm":
notes.append("确认型需观察后续量能是否跟上,防止冲高回落")
elif entry_type == "pullback":
notes.append("回踩型可能继续下探支撑,注意止损纪律")
elif entry_type == "launch":
notes.append("启动型整理可能延长,注意时间成本")
elif entry_type == "reversal":
notes.append("反转型可能二次探底,确认底部后再加仓")
if market.temperature < 30:
notes.append("市场情绪偏冷,系统性风险较高")
elif market.temperature < 50:
notes.append("市场情绪一般,注意仓位控制")
if tech:
if tech.position_score < 30:
notes.append(f"近期涨幅较大(5日{tech.rally_pct_5d}%),追高风险")
if tech.rally_pct_10d > 20:
notes.append(f"10日累涨{tech.rally_pct_10d}%,警惕回调")
if not notes:
return "注意设好止损,控制仓位"
return "".join(notes)
def _summarize_for_llm(df, entry_signal: dict, tech_signal: TechnicalSignal | None) -> str:
"""生成 K 线分析结论供 LLM 判断(输出结论而非原始数据)"""
import pandas as pd
last = df.iloc[-1]
parts = []
# ── 趋势结论 ──
ma_fields = ["ma5", "ma10", "ma20", "ma60"]
ma_vals = {m: last.get(m) for m in ma_fields}
trend_desc = "趋势不明"
all_ma_valid = all(ma_vals.get(m) is not None and not pd.isna(ma_vals[m]) for m in ma_fields)
if all_ma_valid:
if ma_vals["ma5"] > ma_vals["ma10"] > ma_vals["ma20"] > ma_vals["ma60"]:
trend_desc = "强势多头排列(MA5>MA10>MA20>MA60)"
elif ma_vals["ma5"] > ma_vals["ma10"] > ma_vals["ma20"]:
trend_desc = "中短期多头(MA5>MA10>MA20)"
elif ma_vals["ma5"] > ma_vals["ma20"]:
trend_desc = "偏多(MA5在MA20上方)"
elif ma_vals["ma5"] < ma_vals["ma10"] < ma_vals["ma20"]:
trend_desc = "空头排列,趋势偏弱"
else:
trend_desc = "均线交织,趋势震荡"
# MA20 方向
if len(df) >= 5 and not pd.isna(last.get("ma20")) and not pd.isna(df.iloc[-5].get("ma20")):
ma20_now = last["ma20"]
ma20_5d = df.iloc[-5]["ma20"]
if ma20_5d > 0:
ma20_pct = (ma20_now - ma20_5d) / ma20_5d * 100
if ma20_pct > 2:
trend_desc += "MA20快速上扬"
elif ma20_pct > 0:
trend_desc += "MA20缓慢上行"
else:
trend_desc += "MA20走平或下行"
parts.append(trend_desc)
# ── 量价结论 ──
if len(df) >= 10:
recent = df.tail(10)
up_days = recent[recent["pct_chg"] > 0]
down_days = recent[recent["pct_chg"] <= 0]
vol_conclusion = ""
if len(up_days) > 0 and len(down_days) > 0:
avg_up_vol = up_days["vol"].mean()
avg_down_vol = down_days["vol"].mean()
if avg_down_vol > 0:
ratio = avg_up_vol / avg_down_vol
if ratio > 1.5:
vol_conclusion = f"量价健康(上涨均量/下跌均量={ratio:.1f},需求主导)"
elif ratio > 1.0:
vol_conclusion = f"量价尚可(量比={ratio:.1f},需求略强)"
else:
vol_conclusion = f"量价偏弱(量比={ratio:.1f},供给主导)"
if not vol_conclusion:
vol_conclusion = "量价数据不足"
# 最近5日量能变化
recent_5 = df.tail(5)
vol_ma5 = recent_5["vol"].mean()
vol_ma10 = df.tail(10)["vol"].mean()
if vol_ma10 > 0:
vol_ratio = vol_ma5 / vol_ma10
if vol_ratio > 1.5:
vol_conclusion += "近5日明显放量"
elif vol_ratio < 0.7:
vol_conclusion += "近5日缩量"
parts.append(vol_conclusion)
# ── MACD 结论 ──
dif = last.get("dif", 0) or 0
dea = last.get("dea", 0) or 0
macd_desc = ""
if len(df) >= 3:
prev_dif = df.iloc[-2].get("dif", 0) or 0
prev_dea = df.iloc[-2].get("dea", 0) or 0
if dif > dea and prev_dif <= prev_dea:
macd_desc = "MACD刚金叉"
elif dif > dea:
macd_desc = "MACD金叉运行中"
elif dif < dea and prev_dif >= prev_dea:
macd_desc = "MACD刚死叉"
elif dif < dea:
macd_desc = "MACD死叉运行中"
if dif > 0:
macd_desc += ",零轴上方(偏多)"
else:
macd_desc += ",零轴下方(偏空)"
parts.append(macd_desc or "MACD数据不足")
# ── RSI 结论 ──
rsi = last.get("rsi14", 50)
if not pd.isna(rsi):
if rsi > 80:
parts.append(f"RSI14={rsi:.0f},超买区,回调风险大")
elif rsi > 70:
parts.append(f"RSI14={rsi:.0f},偏高,注意追高风险")
elif rsi >= 40:
parts.append(f"RSI14={rsi:.0f},健康区间")
else:
parts.append(f"RSI14={rsi:.0f},偏低,可能超卖")
# ── 价格位置结论 ──
if tech_signal:
pos_parts = []
if tech_signal.rally_pct_5d > 15:
pos_parts.append(f"5日已涨{tech_signal.rally_pct_5d}%,追高风险大")
elif tech_signal.rally_pct_5d > 8:
pos_parts.append(f"5日涨{tech_signal.rally_pct_5d}%,短期有一定涨幅")
elif tech_signal.rally_pct_5d > 0:
pos_parts.append(f"5日涨{tech_signal.rally_pct_5d}%,涨幅温和")
else:
pos_parts.append(f"5日跌{abs(tech_signal.rally_pct_5d)}%,回调中")
if tech_signal.distance_from_high >= 0:
pos_parts.append("处于60日新高附近")
elif tech_signal.distance_from_high > -5:
pos_parts.append(f"距60日高点{abs(tech_signal.distance_from_high):.1f}%")
else:
pos_parts.append(f"距60日高点{abs(tech_signal.distance_from_high):.1f}%,位置较低")
parts.append("位置: " + "".join(pos_parts))
# ── 近5日价格走势简述 ──
if len(df) >= 5:
recent_5 = df.tail(5)
closes = recent_5["close"].tolist()
first_c = closes[0]
last_c = closes[-1]
pct_5d = (last_c - first_c) / first_c * 100
parts.append(f"当前价: {last_c:.2f}5日{'' if pct_5d >= 0 else ''}{abs(pct_5d):.1f}%")
return "\n".join(parts)