智能荐股系统 v1.0.0:股票池/行情/新闻RAG/机构持仓/多因子评分/AI深度研报
This commit is contained in:
@@ -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):
|
||||
"""调用 DeepSeek(OpenAI 兼容)。返回最终 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,))
|
||||
Reference in New Issue
Block a user