智能荐股系统 v1.0.0:股票池/行情/新闻RAG/机构持仓/多因子评分/AI深度研报

This commit is contained in:
2026-08-19 19:36:32 +08:00
commit 90a9f9b212
29 changed files with 3332 additions and 0 deletions
+223
View File
@@ -0,0 +1,223 @@
# -*- coding: utf-8 -*-
"""
AI 分析引擎:DeepSeek 深度研报 + RAG 增强
- 检索:股票相关新闻(向量语义)+ 公司概况 + 机构动向/基金持仓(结构化)
- 生成:结构化工研报(公司概况/基本面/技术面/消息面/机构动向/风险提示/操作建议)
"""
import logging
import threading
import time
import requests
from config import (LLM_API_KEY, LLM_BASE_URL, LLM_MAX_TOKENS, LLM_MODEL,
LLM_TEMPERATURE, LLM_TIMEOUT, CHROMA_NEWS_COLLECTION,
CHROMA_PROFILE_COLLECTION)
from database import query, query_one, execute
from rag.vector_store import query_vectors
log = logging.getLogger("analyst")
_jobs = {} # code -> {status, report, error, ts}
_jobs_lock = threading.Lock()
# ------------------------------------------------------------------ LLM
def llm_chat(messages, max_tokens=None, temperature=None, timeout=None):
"""调用 DeepSeekOpenAI 兼容)。返回最终 content(忽略推理过程)"""
resp = requests.post(
f"{LLM_BASE_URL}/chat/completions",
headers={"Authorization": f"Bearer {LLM_API_KEY}"},
json={
"model": LLM_MODEL,
"messages": messages,
"max_tokens": max_tokens or LLM_MAX_TOKENS,
"temperature": LLM_TEMPERATURE if temperature is None else temperature,
"stream": False,
},
timeout=timeout or LLM_TIMEOUT,
)
resp.raise_for_status()
data = resp.json()
try:
return data["choices"][0]["message"].get("content") or ""
except (KeyError, IndexError):
return ""
# ------------------------------------------------------------------ RAG 检索
def _rag_news(code, stock_name, query_text, top_k=6):
"""检索个股相关新闻(向量语义,按 code 过滤)"""
try:
where = {"code": code}
hits = query_vectors(query_text, n_results=top_k, where=where,
name=CHROMA_NEWS_COLLECTION)
out = []
for h in hits:
m = h.get("metadata", {})
out.append({
"title": m.get("title", ""),
"date": m.get("date", ""),
"sentiment": m.get("sentiment", 0),
"text": h.get("document", "")[:400],
})
return out
except Exception as e:
log.warning("RAG news fail: %s", e)
return []
def _rag_profile(code):
try:
hits = query_vectors("公司主营业务与基本面", n_results=1,
where={"code": code}, name=CHROMA_PROFILE_COLLECTION)
if hits:
return hits[0].get("document", "")
except Exception:
pass
return ""
def _inst_summary(code):
"""机构评级 + 基金持仓摘要(结构化)"""
ratings = query(
"SELECT inst_name, rating, target_price, rating_date, prev_rating "
"FROM inst_ratings WHERE stock_code=? ORDER BY rating_date DESC LIMIT 6", (code,))
holdings = query(
"SELECT inst_name, quarter, hold_value, change_pct FROM fund_holdings "
"WHERE stock_code=? ORDER BY quarter DESC, hold_value DESC LIMIT 6", (code,))
return ratings, holdings
# ------------------------------------------------------------------ 报告生成
def _fmt_indicators(ind):
if not ind:
return "(暂无技术数据)"
lines = [
f"- 最新价 {ind.get('close')},当日 {ind.get('change_pct', 0):+.2f}%",
f"- MA5={ind.get('ma5')} / MA10={ind.get('ma10')} / MA20={ind.get('ma20')} / MA60={ind.get('ma60')}",
f"- RSI(14)={ind.get('rsi')}KDJ K/D/J={ind.get('kdj_k')}/{ind.get('kdj_d')}/{ind.get('kdj_j')}",
f"- MACD DIF={ind.get('dif')} / DEA={ind.get('dea')} / 柱={ind.get('macd')}",
f"- 量比 {ind.get('vol_ratio')}5日涨幅 {ind.get('chg_5d', 0):+.2f}%20日涨幅 {ind.get('chg_20d', 0):+.2f}%",
f"- 近120日区间 {ind.get('low_52w')} ~ {ind.get('high_52w')}20日波动率 {ind.get('volatility')}%",
]
return "\n".join(lines)
def _build_prompt(stock, ind, news_hits, profile, ratings, holdings, score, focus):
rated = "".join(f"{r['inst_name']}({r['rating']},目标{r['target_price']})" for r in ratings) or "暂无"
held = "".join(f"{h['inst_name']} {h['quarter']}持仓{h['hold_value']:.0f}万 环比{h['change_pct']:+.1f}%" for h in holdings) or "暂无"
news_text = "\n\n".join(
f"{n['date']}{n['title']}】(情感{n['sentiment']:+.2f})\n{n['text']}" for n in news_hits
) or "(检索到相关资讯较少)"
return f"""你是资深A股投顾,请基于下方【资料】对股票 {stock['name']}({stock['code']}) 输出一份结构化工研报。
【资料】
公司概况:
{profile or stock.get('description', '暂无')}
技术面:
{_fmt_indicators(ind)}
综合评分:{score.get('total')} 分(评级:{score.get('rating')}),分项:趋势{score.get('trend')}/动量{score.get('momentum')}/技术{score.get('technical')}/量能{score.get('volume')}/消息{score.get('news')}/机构{score.get('institutional')}
机构评级:{rated}
基金持仓:{held}
相关资讯(RAG 语义检索):
{news_text}
用户关注点:{focus or '整体投资价值'}
【输出要求】用 Markdown 输出,结构如下:
## 一、公司概况与基本面
## 二、技术面解读
## 三、消息面与市场情绪
## 四、机构动向
## 五、风险提示
## 六、操作建议(给出 目标区间 / 支撑位 / 压力位,说明短线与中线思路)
注意:内容需严格基于上述资料,数据为模拟数据,结尾加一句「以上内容基于模拟数据生成,仅供系统演示,不构成投资建议」。"""
def generate_report_sync(code, focus=""):
"""同步生成报告(后台线程调用)"""
stock = query_one("SELECT * FROM stocks WHERE code=?", (code,))
if not stock:
return {"error": "股票不存在"}
ind = _indicators_for(code)
score = _score_for(code, ind)
hits = _rag_news(code, stock["name"], f"{stock['name']} {focus or '投资价值 业绩 利好利空'} {ind.get('close','')}")
profile = _rag_profile(code)
ratings, holdings = _inst_summary(code)
prompt = _build_prompt(stock, ind, hits, profile, ratings, holdings, score, focus)
try:
report = llm_chat([
{"role": "system", "content": "你是一名严谨专业的A股投资顾问,输出结构化、简洁、可执行的研报。"},
{"role": "user", "content": prompt},
])
report = report.strip()
if not report:
raise RuntimeError("LLM 返回为空")
execute("INSERT OR REPLACE INTO analysis_cache(code, report, created_at) VALUES(?,?,datetime('now','localtime'))",
(code, report))
return {"report": report, "ts": time.time()}
except Exception as e:
log.exception("gen report fail")
return {"error": str(e)}
def _indicators_for(code):
"""从 DB 读取日线并算指标(避免循环依赖 app)"""
from engine.indicators import compute_indicators
rows = query("SELECT date,open,high,low,close,volume FROM stock_daily WHERE code=? ORDER BY date ASC", (code,))
return compute_indicators(rows)
def _score_for(code, ind):
from engine.scoring import score_stock
# 新闻情感(与 app 端口径一致:精确/前缀/后缀三种匹配)
n = query_one(
"SELECT AVG(sentiment) AS s FROM news WHERE (related_stocks=? OR related_stocks LIKE ? OR related_stocks LIKE ?) "
"AND publish_date >= date('now','-7 day')",
(code, f"%,{code}", f"{code},%"))
news_score = n["s"] if n and n["s"] is not None else 0.0
# 机构热度
st = query_one(
"SELECT COUNT(*) AS c FROM inst_ratings WHERE stock_code=? AND rating IN ('买入','增持') "
"AND rating_date >= date('now','-30 day')", (code,))
inst_count = st["c"] if st else 0
inst_score = min(1.0, inst_count / 4.0)
return score_stock(ind, news_score, inst_score)
# ------------------------------------------------------------------ 异步任务
def submit_report(code, focus=""):
"""提交后台生成任务,立即返回"""
with _jobs_lock:
if _jobs.get(code, {}).get("status") == "running":
return {"status": "running"}
_jobs[code] = {"status": "running", "report": None, "error": None, "ts": time.time()}
threading.Thread(target=_run_job, args=(code, focus), daemon=True).start()
return {"status": "running"}
def _run_job(code, focus):
try:
res = generate_report_sync(code, focus)
with _jobs_lock:
if res.get("error"):
_jobs[code] = {"status": "error", "error": res["error"], "ts": time.time()}
else:
_jobs[code] = {"status": "done", "report": res["report"], "ts": time.time()}
except Exception as e:
with _jobs_lock:
_jobs[code] = {"status": "error", "error": str(e), "ts": time.time()}
def report_status(code):
with _jobs_lock:
return dict(_jobs.get(code, {}))
def get_cached_report(code):
return query_one("SELECT report, created_at FROM analysis_cache WHERE code=?", (code,))
+164
View File
@@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
"""
技术指标计算:MA / RSI / MACD / KDJ / 量比 / 动量 / 波动率
输入 bars:按日期升序的 [{date, open, high, low, close, volume}, ...]
"""
import math
def _sma(vals, n):
if len(vals) < n:
return None
return sum(vals[-n:]) / n
def _ema(vals, n):
if not vals:
return None
k = 2 / (n + 1)
e = vals[0]
for v in vals[1:]:
e = v * k + e * (1 - k)
return e
def _ema_series(vals, n):
out = []
if not vals:
return out
k = 2 / (n + 1)
e = vals[0]
out.append(e)
for v in vals[1:]:
e = v * k + e * (1 - k)
out.append(e)
return out
def rsi14(closes):
"""Wilder RSI(14)"""
if len(closes) < 15:
return 50.0
gains, losses = [], []
for i in range(1, len(closes)):
chg = closes[i] - closes[i - 1]
gains.append(max(chg, 0))
losses.append(max(-chg, 0))
avg_g = sum(gains[:14]) / 14
avg_l = sum(losses[:14]) / 14
for i in range(14, len(gains)):
avg_g = (avg_g * 13 + gains[i]) / 14
avg_l = (avg_l * 13 + losses[i]) / 14
if avg_l == 0:
return 100.0
rs = avg_g / avg_l
return 100 - 100 / (1 + rs)
def kdj(bars, n=9, k_smooth=3, d_smooth=3):
"""返回 (K, D, J)"""
if len(bars) < n:
return 50.0, 50.0, 50.0
k, d = 50.0, 50.0
for i in range(n - 1, len(bars)):
window = bars[i - n + 1:i + 1]
low_n = min(b["low"] for b in window)
high_n = max(b["high"] for b in window)
rsv = 0 if high_n == low_n else (bars[i]["close"] - low_n) / (high_n - low_n) * 100
k = (k * (k_smooth - 1) + rsv) / k_smooth
d = (d * (d_smooth - 1) + k) / d_smooth
j = 3 * k - 2 * d
return k, d, j
def compute_indicators(bars):
"""计算全部技术指标,返回 dict(最新值 + 序列用于画图)"""
if not bars:
return {}
closes = [b["close"] for b in bars]
last = bars[-1]
prev = bars[-2] if len(bars) > 1 else last
ma5 = _sma(closes, 5)
ma10 = _sma(closes, 10)
ma20 = _sma(closes, 20)
ma60 = _sma(closes, 60)
# MACD
ema12 = _ema_series(closes, 12)
ema26 = _ema_series(closes, 26)
dif_series = [e12 - e26 for e12, e26 in zip(ema12, ema26)]
dea_series = _ema_series(dif_series, 9)
dif = dif_series[-1] if dif_series else 0
dea = dea_series[-1] if dea_series else 0
macd = (dif - dea) * 2
rsi = rsi14(closes)
k, d, j = kdj(bars)
# 涨跌幅
chg_1d = (last["close"] - prev["close"]) / prev["close"] * 100 if prev["close"] else 0
chg_5d = (last["close"] - closes[-6]) / closes[-6] * 100 if len(closes) > 6 else chg_1d
chg_10d = (last["close"] - closes[-11]) / closes[-11] * 100 if len(closes) > 11 else chg_1d
chg_20d = (last["close"] - closes[-21]) / closes[-21] * 100 if len(closes) > 21 else chg_1d
# 量比 = 今日量 / 前5日均量
vol_ratio = 1.0
if len(bars) > 6:
avg5 = sum(b["volume"] for b in bars[-6:-1]) / 5
if avg5 > 0:
vol_ratio = last["volume"] / avg5
# 20日波动率(年化近似省略,日波动)
returns = []
for i in range(1, len(closes)):
if closes[i - 1]:
returns.append((closes[i] - closes[i - 1]) / closes[i - 1])
vol20 = (sum(r * r for r in returns[-20:]) / max(len(returns[-20:]), 1)) ** 0.5 * 100 if returns else 0
# 区间高低(近120日)
window = bars[-120:] if len(bars) > 120 else bars
high52 = max(b["high"] for b in window)
low52 = min(b["low"] for b in window)
# 均线多头排列
if ma5 and ma10 and ma20:
bull = ma5 > ma10 > ma20
partial = ma5 > ma10 or ma10 > ma20
else:
bull, partial = False, False
return {
"date": last["date"],
"close": last["close"],
"open": last["open"],
"high": last["high"],
"low": last["low"],
"volume": last["volume"],
"change_pct": round(chg_1d, 2),
"chg_5d": round(chg_5d, 2),
"chg_10d": round(chg_10d, 2),
"chg_20d": round(chg_20d, 2),
"ma5": round(ma5, 2) if ma5 else None,
"ma10": round(ma10, 2) if ma10 else None,
"ma20": round(ma20, 2) if ma20 else None,
"ma60": round(ma60, 2) if ma60 else None,
"rsi": round(rsi, 2),
"kdj_k": round(k, 2),
"kdj_d": round(d, 2),
"kdj_j": round(j, 2),
"dif": round(dif, 3),
"dea": round(dea, 3),
"macd": round(macd, 3),
"vol_ratio": round(vol_ratio, 2),
"volatility": round(vol20, 2),
"high_52w": round(high52, 2),
"low_52w": round(low52, 2),
"trend_bull": bull,
"trend_partial": partial,
"bars": [
{"date": b["date"], "open": b["open"], "high": b["high"],
"low": b["low"], "close": b["close"], "volume": b["volume"]}
for b in bars
],
}
+150
View File
@@ -0,0 +1,150 @@
# -*- 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 dictnews_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. 动量 205日涨幅)
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. 技术 15RSI
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]