224 lines
9.1 KiB
Python
224 lines
9.1 KiB
Python
# -*- 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,))
|