151 lines
4.3 KiB
Python
151 lines
4.3 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
荐股评分引擎:多因子打分模型(满分100)
|
||
- 趋势 25分:均线多头排列 + 站上MA20
|
||
- 动量 20分:5日涨幅区间映射
|
||
- 技术 15分:RSI健康区间 / 超买超卖
|
||
- 量能 10分:量比
|
||
- 消息 15分:近7日相关新闻情感均值(RAG 信号)
|
||
- 机构 15分:近30日评级上调 + 基金持仓环比增持
|
||
|
||
评级:>=82 强烈推荐 / >=68 推荐 / >=55 关注 / <55 观望
|
||
"""
|
||
RATINGS = [
|
||
(82, "强烈推荐"),
|
||
(68, "推荐"),
|
||
(55, "关注"),
|
||
(-1e9, "观望"),
|
||
]
|
||
|
||
|
||
def _rating(score):
|
||
for threshold, name in RATINGS:
|
||
if score >= threshold:
|
||
return name
|
||
return "观望"
|
||
|
||
|
||
def _bracket(v, cuts):
|
||
"""v 落在 [val, 分数] 区间的第一个匹配"""
|
||
for hi, lo, score in cuts:
|
||
if hi is None or v <= hi:
|
||
if v >= lo:
|
||
return score
|
||
return 0
|
||
|
||
|
||
def score_stock(ind, news_score, inst_score):
|
||
"""ind: indicators dict;news_score: -1~1(无新闻用0);inst_score: 0~1 归一化机构热度"""
|
||
s = {}
|
||
|
||
# 1. 趋势 25
|
||
if ind.get("trend_bull"):
|
||
s["trend"] = 25
|
||
elif ind.get("trend_partial"):
|
||
s["trend"] = 17
|
||
else:
|
||
s["trend"] = 8
|
||
ma20 = ind.get("ma20")
|
||
if ma20 and ind.get("close", 0) >= ma20:
|
||
s["trend"] = min(25, s["trend"] + 4)
|
||
|
||
# 2. 动量 20(5日涨幅)
|
||
chg5 = ind.get("chg_5d", 0)
|
||
if chg5 > 12:
|
||
s["momentum"] = 16 # 过急,扣分防追高
|
||
elif chg5 > 6:
|
||
s["momentum"] = 20
|
||
elif chg5 > 2:
|
||
s["momentum"] = 15
|
||
elif chg5 > -2:
|
||
s["momentum"] = 10
|
||
elif chg5 > -6:
|
||
s["momentum"] = 6
|
||
else:
|
||
s["momentum"] = 3
|
||
|
||
# 3. 技术 15(RSI)
|
||
rsi = ind.get("rsi", 50)
|
||
if 50 <= rsi <= 68:
|
||
s["technical"] = 15
|
||
elif 40 <= rsi < 50:
|
||
s["technical"] = 11
|
||
elif 68 < rsi <= 80:
|
||
s["technical"] = 8 # 接近超买
|
||
elif rsi < 35:
|
||
s["technical"] = 9 # 超卖修复机会
|
||
else:
|
||
s["technical"] = 5
|
||
|
||
# 4. 量能 10(量比)
|
||
vr = ind.get("vol_ratio", 1.0)
|
||
if vr >= 2.0:
|
||
s["volume"] = 10
|
||
elif vr >= 1.3:
|
||
s["volume"] = 8
|
||
elif vr >= 0.8:
|
||
s["volume"] = 6
|
||
else:
|
||
s["volume"] = 4
|
||
|
||
# 5. 消息 15
|
||
s["news"] = round(max(0, min(15, (news_score + 1) / 2 * 15)), 1)
|
||
|
||
# 6. 机构 15
|
||
s["institutional"] = round(inst_score * 15, 1)
|
||
|
||
total = round(sum(s.values()), 1)
|
||
return {
|
||
"total": total,
|
||
"trend": s["trend"],
|
||
"momentum": s["momentum"],
|
||
"technical": s["technical"],
|
||
"volume": s["volume"],
|
||
"news": s["news"],
|
||
"institutional": s["institutional"],
|
||
"rating": _rating(total),
|
||
"score_parts": s,
|
||
}
|
||
|
||
|
||
def build_reasons(ind, news_score, inst_up, hold_up):
|
||
"""生成规则化推荐理由(供列表直接展示,无需 LLM)"""
|
||
reasons = []
|
||
if ind.get("trend_bull"):
|
||
reasons.append("均线呈多头排列,中短期趋势向上")
|
||
elif ind.get("close", 0) >= (ind.get("ma20") or 0):
|
||
reasons.append("股价站上20日均线,趋势转强")
|
||
else:
|
||
reasons.append("均线空头排列,趋势偏弱,注意风险")
|
||
|
||
chg5 = ind.get("chg_5d", 0)
|
||
if chg5 >= 6:
|
||
reasons.append(f"5日涨幅{chg5:+.1f}%,动量强劲")
|
||
elif chg5 < -6:
|
||
reasons.append(f"5日跌幅{chg5:+.1f}%,弱势调整")
|
||
else:
|
||
reasons.append(f"5日涨跌{chg5:+.1f}%,动量平稳")
|
||
|
||
rsi = ind.get("rsi", 50)
|
||
if rsi >= 70:
|
||
reasons.append(f"RSI {rsi:.0f} 超买,短线回调风险增大")
|
||
elif rsi <= 30:
|
||
reasons.append(f"RSI {rsi:.0f} 超卖,存在修复反弹机会")
|
||
|
||
vr = ind.get("vol_ratio", 1.0)
|
||
if vr >= 1.5:
|
||
reasons.append(f"量比{vr:.2f},放量明显,资金活跃")
|
||
elif vr < 0.7:
|
||
reasons.append(f"量比{vr:.2f},缩量整理")
|
||
|
||
if news_score > 0.25:
|
||
reasons.append("近期相关消息面偏正面,情绪回暖")
|
||
elif news_score < -0.25:
|
||
reasons.append("近期消息面偏空,注意利空扰动")
|
||
|
||
if inst_up > 0:
|
||
reasons.append(f"近30日{inst_up}家机构给出正面评级")
|
||
if hold_up > 0:
|
||
reasons.append("基金最新季度环比增持")
|
||
return reasons[:4]
|