1215 lines
48 KiB
Python
1215 lines
48 KiB
Python
"""趋势突破统一筛选器(自上而下方案,中短线交易定位)
|
||
|
||
三阶段管道:
|
||
Step 1: 板块定位 — 找到有资金流入的热门板块 (3-5个)
|
||
Step 2: 板块内选股 — 在热门板块成分股中筛出有资金流入的候选 (30-50只)
|
||
Step 3: 深度分析 — 供需 + 价格行为 + 趋势 + LLM (10-15只推荐)
|
||
|
||
评分公式:供需关系 50% + 价格行为 40% + 趋势 10%
|
||
板块和资金流作为前置过滤条件,板块涨停数作为情绪奖励。
|
||
|
||
风险乘数:惩罚取最大而非叠加(防过度惩罚),奖励可叠加。
|
||
|
||
数据源:
|
||
- 盘中模式:Tushare 日线 + 腾讯实时行情 + 东方财富5分钟K线
|
||
- 盘后模式:Tushare 当日完整数据
|
||
|
||
止损止盈:基于市场结构(阻力位/支撑MA/近期低点),而非固定百分比。
|
||
"""
|
||
|
||
import asyncio
|
||
import logging
|
||
import traceback
|
||
|
||
import pandas as pd
|
||
|
||
from app.analysis.market_temp import 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 信号强度转换为分数调整
|
||
# 调整幅度温和,保底不低于 60,避免推荐被过滤掉
|
||
strength = llm_data.get("strength", "中")
|
||
llm_signal = llm_data.get("signal", "HOLD")
|
||
|
||
if llm_signal == "SKIP":
|
||
# 降分但保底 60,排序靠后但不消失
|
||
rec.score = max(60, round(rec.score - 15, 1))
|
||
elif llm_signal == "HOLD":
|
||
rec.score = max(60, round(rec.score - 5, 1))
|
||
elif llm_signal == "BUY" and strength == "强":
|
||
rec.score = round(rec.score + 10, 1)
|
||
elif llm_signal == "BUY" and strength == "中":
|
||
rec.score = round(rec.score + 5, 1)
|
||
else: # BUY + 弱
|
||
pass # 不调整
|
||
|
||
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}")
|
||
from app.db.error_logger import log_error
|
||
await log_error("screener", f"LLM 逐股分析失败, 仅使用量化评分: {e}", detail=traceback.format_exc())
|
||
|
||
return recommendations
|
||
|
||
|
||
# ── 价格行为评分 ──
|
||
|
||
|
||
def _score_price_action(df, entry_signal: dict) -> float:
|
||
"""价格行为学评分 (0-100)
|
||
|
||
纯粹关注 K 线形态和量价配合,不重复评估趋势/均线因素。
|
||
|
||
维度:
|
||
- K线形态强度 (35): 实体占比、收盘位置、下影线
|
||
- 量价配合 (35): 放量/缩量与价格方向的配合度
|
||
- 入场形态质量 (30): 各信号类型的形态完成度
|
||
"""
|
||
import pandas as pd
|
||
score = 0
|
||
last = df.iloc[-1]
|
||
details = entry_signal.get("details", {})
|
||
signal_type = entry_signal.get("signal_type")
|
||
|
||
# K线形态强度 (35)
|
||
day_range = last["high"] - last["low"]
|
||
if day_range > 0:
|
||
# 实体占比(实体/全振幅)
|
||
body = abs(last["close"] - last["open"])
|
||
body_ratio = body / day_range
|
||
if body_ratio > 0.7:
|
||
score += 20 # 大实体,方向明确
|
||
elif body_ratio > 0.4:
|
||
score += 12
|
||
elif body_ratio > 0.2:
|
||
score += 6
|
||
|
||
# 收盘位置(越接近高点越好)
|
||
close_position = (last["close"] - last["low"]) / day_range
|
||
if close_position > 0.8:
|
||
score += 10 # 收在上部 20%
|
||
elif close_position > 0.6:
|
||
score += 6
|
||
elif close_position > 0.4:
|
||
score += 3
|
||
|
||
# 下影线(回踩型/启动型利好)
|
||
lower_wick = (last["open"] - last["low"]) if last["close"] > last["open"] else (last["close"] - last["low"])
|
||
if lower_wick > 0:
|
||
wick_ratio = lower_wick / day_range
|
||
if signal_type and signal_type.value in ("pullback", "reversal") and wick_ratio > 0.2:
|
||
score += 5 # 回踩型/反转型有下影线支撑
|
||
|
||
# 量价配合 (35)
|
||
vol_ma_col = "vol_ma5" if "vol_ma5" in df.columns else None
|
||
if vol_ma_col and not pd.isna(last[vol_ma_col]) and last[vol_ma_col] > 0:
|
||
vol_ratio = last["vol"] / last[vol_ma_col]
|
||
price_up = last["pct_chg"] > 0 if "pct_chg" in df.columns else last["close"] > last["open"]
|
||
|
||
if price_up and vol_ratio > 2.0:
|
||
score += 35 # 放量大阳
|
||
elif price_up and vol_ratio > 1.5:
|
||
score += 25
|
||
elif price_up and vol_ratio > 1.2:
|
||
score += 18
|
||
elif not price_up and vol_ratio < 0.7:
|
||
score += 25 # 缩量回调(良性)
|
||
elif not price_up and vol_ratio < 0.9:
|
||
score += 15
|
||
elif price_up and vol_ratio > 1.0:
|
||
score += 10
|
||
else:
|
||
score += 10
|
||
|
||
# 入场形态质量 (30) — 只评估形态完成度,不涉及均线/MACD
|
||
if signal_type and signal_type.value == "breakout":
|
||
breakout_pct = details.get("breakout_pct", 0)
|
||
vol_ratio = details.get("volume_ratio", 1)
|
||
if breakout_pct > 2 and vol_ratio > 2:
|
||
score += 30
|
||
elif breakout_pct > 1 and vol_ratio > 1.5:
|
||
score += 20
|
||
elif breakout_pct > 0:
|
||
score += 12
|
||
else:
|
||
score += 6
|
||
|
||
elif signal_type and signal_type.value == "breakout_confirm":
|
||
vol_ratio = details.get("volume_ratio", 1)
|
||
confirm_pct = details.get("confirm_pct", 0)
|
||
if vol_ratio > 2 and confirm_pct > 2:
|
||
score += 30
|
||
elif vol_ratio > 1.5 and confirm_pct > 1:
|
||
score += 22
|
||
elif vol_ratio > 1.0:
|
||
score += 14
|
||
else:
|
||
score += 8
|
||
|
||
elif signal_type and signal_type.value == "pullback":
|
||
support_ma = details.get("support_ma", "")
|
||
shrink = details.get("volume_shrink_ratio", 1)
|
||
if support_ma == "MA20" and shrink < 0.6:
|
||
score += 30
|
||
elif support_ma == "MA20":
|
||
score += 22
|
||
elif support_ma == "MA10" and shrink < 0.6:
|
||
score += 18
|
||
else:
|
||
score += 10
|
||
|
||
elif signal_type and signal_type.value == "launch":
|
||
range_pct = details.get("price_range_pct", 10)
|
||
if range_pct < 3:
|
||
score += 30
|
||
elif range_pct < 5:
|
||
score += 20
|
||
else:
|
||
score += 10
|
||
|
||
elif signal_type and signal_type.value == "reversal":
|
||
reversal_pct = details.get("reversal_pct", 0)
|
||
vol_ratio = details.get("volume_ratio", 1)
|
||
if reversal_pct > 5 and vol_ratio > 2.5:
|
||
score += 30
|
||
elif reversal_pct > 3 and vol_ratio > 2:
|
||
score += 22
|
||
elif reversal_pct > 3:
|
||
score += 14
|
||
else:
|
||
score += 8
|
||
|
||
else:
|
||
score += 10
|
||
|
||
return min(score, 100)
|
||
|
||
|
||
# ── 趋势评分 ──
|
||
|
||
|
||
def _score_trend(df) -> float:
|
||
"""趋势评分 (0-100)
|
||
|
||
维度:
|
||
- 均线排列 (40): MA5>MA10>MA20>MA60
|
||
- 更高高点/更高低点结构 (35): 近 20 日价格结构
|
||
- MA20 方向 (25): MA20 是否持续上行
|
||
"""
|
||
import pandas as pd
|
||
score = 0
|
||
last = df.iloc[-1]
|
||
|
||
# 均线排列 (40)
|
||
ma_cols = [c for c in ["ma5", "ma10", "ma20", "ma60"] if c in df.columns]
|
||
if len(ma_cols) >= 4 and not any(pd.isna(last[c]) for c in ma_cols):
|
||
if last["ma5"] > last["ma10"] > last["ma20"] > last["ma60"]:
|
||
score += 40 # 完美多头
|
||
elif last["ma5"] > last["ma10"] > last["ma20"]:
|
||
score += 28
|
||
elif last["ma5"] > last["ma20"]:
|
||
score += 15
|
||
elif "ma5" in df.columns and "ma20" in df.columns:
|
||
if not pd.isna(last["ma5"]) and not pd.isna(last["ma20"]) and last["ma5"] > last["ma20"]:
|
||
score += 15
|
||
|
||
# 更高高点/更高低点结构 (35)
|
||
if len(df) >= 20:
|
||
recent = df.tail(20)
|
||
# 检查高点抬升
|
||
first_10_high = recent["high"].iloc[:10].max()
|
||
second_10_high = recent["high"].iloc[10:].max()
|
||
# 检查低点抬升
|
||
first_10_low = recent["low"].iloc[:10].min()
|
||
second_10_low = recent["low"].iloc[10:].min()
|
||
|
||
if second_10_high > first_10_high and second_10_low > first_10_low:
|
||
score += 35 # 既抬高点又抬低点,最健康
|
||
elif second_10_high > first_10_high:
|
||
score += 20 # 至少高点抬升
|
||
elif second_10_low > first_10_low:
|
||
score += 12 # 至少低点抬升
|
||
|
||
# MA20 方向 (25)
|
||
if "ma20" in df.columns and len(df) >= 5:
|
||
ma20_now = last["ma20"]
|
||
ma20_5d = df.iloc[-5]["ma20"]
|
||
if not pd.isna(ma20_now) and not pd.isna(ma20_5d) and ma20_5d > 0:
|
||
ma20_pct = (ma20_now - ma20_5d) / ma20_5d * 100
|
||
if ma20_pct > 2:
|
||
score += 25
|
||
elif ma20_pct > 1:
|
||
score += 18
|
||
elif ma20_pct > 0:
|
||
score += 10
|
||
|
||
return min(score, 100)
|
||
|
||
|
||
# ── 辅助函数 ──
|
||
|
||
|
||
def _get_sector_stage(sector_name: str, hot_sectors: list[SectorInfo]) -> str:
|
||
"""获取板块所处阶段"""
|
||
for s in hot_sectors:
|
||
if s.sector_name == sector_name:
|
||
return s.stage
|
||
return "mid"
|
||
|
||
|
||
def _get_sector_heat(sector_name: str, hot_sectors: list[SectorInfo]) -> float:
|
||
"""获取板块热度得分"""
|
||
for s in hot_sectors:
|
||
if s.sector_name == sector_name:
|
||
return s.heat_score
|
||
return 30.0
|
||
|
||
|
||
def _get_sector_limit_up(sector_name: str, hot_sectors: list[SectorInfo]) -> int:
|
||
"""获取板块涨停数"""
|
||
for s in hot_sectors:
|
||
if s.sector_name == sector_name:
|
||
return s.limit_up_count
|
||
return 0
|
||
|
||
|
||
def _get_sector_member_count(sector_name: str, hot_sectors: list[SectorInfo]) -> int:
|
||
"""获取板块成分股数量"""
|
||
for s in hot_sectors:
|
||
if s.sector_name == sector_name:
|
||
return s.member_count
|
||
return 0
|
||
|
||
|
||
def _score_capital_simple(stock: dict) -> float:
|
||
"""资金流简单评分(仅基于已有数据,不额外调 API)"""
|
||
main_net = stock.get("main_net_inflow", 0) or 0
|
||
inflow_ratio = stock.get("inflow_ratio", 0) or 0
|
||
|
||
score = 0
|
||
if main_net > 10000:
|
||
score += 60
|
||
elif main_net > 5000:
|
||
score += 45
|
||
elif main_net > 2000:
|
||
score += 30
|
||
elif main_net > 0:
|
||
score += 15
|
||
|
||
if inflow_ratio > 15:
|
||
score += 40
|
||
elif inflow_ratio > 10:
|
||
score += 30
|
||
elif inflow_ratio > 5:
|
||
score += 20
|
||
elif inflow_ratio > 0:
|
||
score += 10
|
||
|
||
return min(score, 100)
|
||
|
||
|
||
def _generate_entry_timing(signal_type: str, intraday: bool) -> str:
|
||
"""根据信号类型生成进场时机建议"""
|
||
if not intraday:
|
||
return "" # 盘后模式不需要时机建议
|
||
|
||
timing_map = {
|
||
"breakout": "开盘观察是否站稳突破位,午后14:00确认不回落再进场",
|
||
"breakout_confirm": "突破已确认,盘中放量时可直接进场",
|
||
"pullback": "盘中靠近支撑位时分批进场,尾盘14:30确认支撑有效可加仓",
|
||
"launch": "早盘放量确认后即可进场,注意开盘9:30-10:00量能",
|
||
"reversal": "午后13:30确认不回落再进场,避免早盘追高",
|
||
}
|
||
return timing_map.get(signal_type, "盘中观察量价配合,确认信号后进场")
|
||
|
||
|
||
def _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)
|