Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99654de0fd | ||
|
|
eaa40bd26b |
@@ -128,6 +128,7 @@ cd works/stock-advisor
|
||||
| `strategy_backtests` | 策略回测结果(408 条) | strategy/code/metrics/equity/trades |
|
||||
| `settings` | 系统设置 KV | key/value |
|
||||
| `notification_log` | 舆情通知日志 | news_id/importance/status |
|
||||
| `tracking_reports` | 持仓跟踪报告(含数据源/影响度 JSON) | code/report/meta/sources |
|
||||
|
||||
---
|
||||
|
||||
@@ -194,6 +195,22 @@ cd works/stock-advisor
|
||||
|
||||
**防打扰机制**:`monitor_last_news_id` 水位只扫新增;首次启动只建水位不通知历史;单封邮件最多 20 条。
|
||||
|
||||
### 5. 持仓跟踪智能体(engine/agent.py + chain_data.py)
|
||||
|
||||
对**自选股(持仓)**定期主动跟踪,由**大模型智能体 + 工作流**驱动:
|
||||
|
||||
```
|
||||
工作流:读取产业链知识库 → RAG+DB 采集(个股/上游/下游/同业) → LLM 产业链深度分析
|
||||
→ 影响度判定(0-100) → 入库 tracking_reports → 重大变化(≥阈值)邮件通知
|
||||
```
|
||||
|
||||
- **产业链知识库** `chain_data.py`:覆盖 40+ 行业,每个行业定义 上游(供给/成本)/下游(需求/客户)/同业 + 检索关键词(引用池内股票代码便于 RAG 过滤)
|
||||
- **深度分析报告**结构:个股最新动态 / 上游产业链分析 / 下游产业链分析 / 同行业竞争格局 / 产业链传导与投资启示 / 风险提示 / 关注要点
|
||||
- **影响度判定**:大模型输出 JSON(significance / impact_score / change_kind / summary / chain_trend),前置输出防截断,容错解析
|
||||
- **主动调度**:后台调度器按间隔(默认 60 分钟)跟踪全部自选股;支持「立即跟踪全部/单只」
|
||||
- **重大变化通知**:impact_score ≥ 阈值(默认 65)自动邮件
|
||||
- **页面 `/tracking`**:持仓最近跟踪一览 + 全部跟踪记录 + 详情弹窗(报告 + 四环节数据源)
|
||||
|
||||
### 5. 系统设置(/settings)
|
||||
|
||||
- **📧 邮件**:SMTP 服务器/端口/加密模式(plain/starttls/ssl)/账号/密码/收件人 + 测试邮件
|
||||
@@ -250,6 +267,15 @@ cd works/stock-advisor
|
||||
| GET | `/api/ratings/upgrades` | 评级变动榜 |
|
||||
| GET | `/api/holdings/moves` | 基金增减持榜 |
|
||||
|
||||
### 持仓跟踪
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/tracking` | 跟踪报告列表 |
|
||||
| GET | `/api/tracking/stock/<code>` | 某股跟踪记录 |
|
||||
| GET | `/api/tracking/<id>` | 跟踪详情(含数据源) |
|
||||
| POST | `/api/tracking/run` | 立即跟踪全部自选股 |
|
||||
| POST | `/api/tracking/run/<code>` | 立即跟踪单只 |
|
||||
|
||||
### 量化策略
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
@@ -327,6 +353,7 @@ IS_MOCK = False
|
||||
| v1.1.0 | AI 分析历史记录 + 数据源详情页(/analysis/<id>,展示 RAG 新闻/提示词等) |
|
||||
| v1.2.0 | 量化策略模块:6 主流策略全市场回测 + 单股净值曲线 + 交易明细 |
|
||||
| v1.3.0 | 舆情驱动自动化(定期扫新闻→邮件通知)+ 系统设置区(邮件/大模型/监控可配) |
|
||||
| v1.4.0 | 持仓跟踪智能体:产业链上下游深度分析(上游供给/成本、下游需求/景气、同业),影响度判定+重大变化通知 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -55,6 +55,11 @@ def page_strategies():
|
||||
return render_template("strategies.html", service=SERVICE_NAME, is_mock=IS_MOCK)
|
||||
|
||||
|
||||
@app.route("/tracking")
|
||||
def page_tracking():
|
||||
return render_template("tracking.html", service=SERVICE_NAME, is_mock=IS_MOCK)
|
||||
|
||||
|
||||
@app.route("/admin")
|
||||
def page_admin():
|
||||
return render_template("admin.html", service=SERVICE_NAME, is_mock=IS_MOCK)
|
||||
@@ -550,15 +555,17 @@ def api_backtest_rebuild():
|
||||
# ------------------------------------------------------------------ 设置与舆情监控
|
||||
@app.route("/api/settings")
|
||||
def api_settings():
|
||||
from settings import all_settings, mail_config, monitor_config, monitor_state
|
||||
from settings import all_settings, mail_config, monitor_config, monitor_state, \
|
||||
tracking_config, tracking_state
|
||||
from config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL
|
||||
s = all_settings()
|
||||
# 返回带默认值的完整配置
|
||||
mail = mail_config()
|
||||
mono = monitor_config()
|
||||
track = tracking_config()
|
||||
return jsonify({
|
||||
"mail": mail,
|
||||
"monitor": mono,
|
||||
"tracking": {**track, **tracking_state()},
|
||||
"llm": {
|
||||
"base_url": s.get("llm_base_url", LLM_BASE_URL),
|
||||
"api_key": s.get("llm_api_key", LLM_API_KEY),
|
||||
@@ -589,6 +596,11 @@ def api_settings_save():
|
||||
"monitor_sentiment", "monitor_importance", "monitor_keywords"):
|
||||
if k in mono:
|
||||
set_setting(k, mono[k])
|
||||
# 持仓跟踪
|
||||
track = body.get("tracking") or {}
|
||||
for k in ("tracking_enabled", "tracking_interval", "tracking_notify", "tracking_impact_threshold"):
|
||||
if k in track:
|
||||
set_setting(k, track[k])
|
||||
return jsonify({"ok": True, "msg": "设置已保存"})
|
||||
|
||||
|
||||
@@ -637,6 +649,62 @@ def api_monitor_log():
|
||||
return jsonify({"items": notification_log()})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 持仓跟踪
|
||||
@app.route("/api/tracking")
|
||||
def api_tracking_list():
|
||||
from engine.agent import list_reports
|
||||
return jsonify({"items": list_reports()})
|
||||
|
||||
|
||||
@app.route("/api/tracking/stock/<code>")
|
||||
def api_tracking_stock(code):
|
||||
from engine.agent import latest_reports
|
||||
return jsonify({"items": latest_reports(code)})
|
||||
|
||||
|
||||
@app.route("/api/tracking/<int:rid>")
|
||||
def api_tracking_detail(rid):
|
||||
from engine.agent import get_report
|
||||
import json as _json
|
||||
r = get_report(rid)
|
||||
if not r:
|
||||
return jsonify({"error": "记录不存在"}), 404
|
||||
r["meta"] = _json.loads(r["meta"] or "{}")
|
||||
r["sources"] = _json.loads(r["sources"] or "{}")
|
||||
return jsonify(r)
|
||||
|
||||
|
||||
@app.route("/api/tracking/run", methods=["POST"])
|
||||
def api_tracking_run():
|
||||
"""立即跟踪全部自选股(后台)"""
|
||||
import threading
|
||||
|
||||
def run():
|
||||
from engine.agent import track_watchlist
|
||||
try:
|
||||
track_watchlist()
|
||||
except Exception as e:
|
||||
log.error("track run fail: %s", e)
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
return jsonify({"ok": True, "msg": "跟踪任务已启动(逐只分析,约每只30-60秒)"})
|
||||
|
||||
|
||||
@app.route("/api/tracking/run/<code>", methods=["POST"])
|
||||
def api_tracking_run_one(code):
|
||||
from engine.agent import track_stock
|
||||
import threading
|
||||
|
||||
def run():
|
||||
try:
|
||||
track_stock(code)
|
||||
except Exception as e:
|
||||
log.error("track %s fail: %s", code, e)
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
return jsonify({"ok": True, "msg": "跟踪已启动"})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 数据管理
|
||||
@app.route("/api/admin/stats")
|
||||
def api_admin_stats():
|
||||
@@ -694,6 +762,8 @@ def api_admin_healthcheck():
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
from engine.notifier import start_monitor
|
||||
from engine.agent import start_tracking
|
||||
start_monitor()
|
||||
start_tracking()
|
||||
print(f"✅ {SERVICE_NAME} 启动: http://0.0.0.0:{SERVICE_PORT}")
|
||||
app.run(host=SERVICE_HOST, port=SERVICE_PORT, threaded=True)
|
||||
@@ -65,3 +65,11 @@ MONITOR_DEFAULTS = {
|
||||
"monitor_importance": "45", # 重要度阈值(0-100),>= 则通知
|
||||
"monitor_keywords": "回购,中标,减持,问询,停牌,上调,下调,超预期,不及预期,预警,重组,增持,定增,业绩,退市",
|
||||
}
|
||||
|
||||
# ---------------- 持仓跟踪智能体(默认值,可在设置区修改) ----------------
|
||||
TRACKING_DEFAULTS = {
|
||||
"tracking_enabled": "1",
|
||||
"tracking_interval": "60", # 分钟
|
||||
"tracking_notify": "1", # 重大变化时邮件通知
|
||||
"tracking_impact_threshold": "65", # 影响度评分 >= 此值判定为重大变化
|
||||
}
|
||||
+14
-1
@@ -140,6 +140,19 @@ CREATE TABLE IF NOT EXISTS notification_log (
|
||||
sent_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tracking_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL,
|
||||
stock_name TEXT DEFAULT '',
|
||||
industry TEXT DEFAULT '',
|
||||
report TEXT DEFAULT '', -- 产业链深度分析(markdown)
|
||||
meta TEXT DEFAULT '{}', -- JSON:significance/impact_score/summary/新闻统计
|
||||
sources TEXT DEFAULT '{}', -- JSON:个股/上游/下游/同业 采集的资讯 + 指标 + 评级
|
||||
status TEXT DEFAULT 'done',
|
||||
created_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracking_code ON tracking_reports(code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS market_index (
|
||||
date TEXT PRIMARY KEY,
|
||||
sh REAL DEFAULT 0, -- 上证指数(点)
|
||||
@@ -206,7 +219,7 @@ def wipe_all():
|
||||
"""清空业务表(保留结构)+ 重置自增序列,用于重灌数据"""
|
||||
for t in ("stock_daily", "inst_ratings", "fund_holdings", "news",
|
||||
"institutions", "stocks", "watchlist", "analysis_cache", "analysis_history",
|
||||
"market_index", "strategy_backtests", "notification_log"):
|
||||
"market_index", "strategy_backtests", "notification_log", "tracking_reports"):
|
||||
with db() as conn:
|
||||
conn.execute(f'DELETE FROM "{t}"')
|
||||
with db() as conn:
|
||||
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
持仓跟踪智能体(AI Agent + 工作流)
|
||||
|
||||
对持仓/自选股票进行定期深度跟踪,工作流:
|
||||
1. 上下文构建 读取产业链知识库(上游/下游/同业 + 关键词)
|
||||
2. 数据采集 个股动态 + 上游产业链 + 下游产业链 + 同业动态(DB + RAG 向量检索)
|
||||
3. 智能体分析 大模型扮演产业链跟踪分析师,输出深度专业分析 + 影响度判定
|
||||
4. 沉淀与通知 结果入库 tracking_reports;重大变化(impact_score 达阈值)邮件通知
|
||||
|
||||
「智能体」体现在:大模型自主综合个股与产业链多环节信息,输出结构化的
|
||||
个股动态 / 上游供给成本 / 下游需求景气 / 同业竞争 / 传导影响 / 风险与关注要点,
|
||||
并给出 0-100 影响度评分与变化性质判定。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from database import query, query_one, execute
|
||||
from settings import tracking_config, tracking_state, set_tracking_state, mail_config
|
||||
from engine.chain_data import get_chain
|
||||
from engine.indicators import compute_indicators
|
||||
from rag.vector_store import query_vectors
|
||||
from config import CHROMA_NEWS_COLLECTION
|
||||
from engine.analyst import llm_chat
|
||||
|
||||
log = logging.getLogger("agent")
|
||||
|
||||
_track_lock = threading.Lock()
|
||||
_cycle_running = False
|
||||
_jobs = {} # code -> {status, error, ts}
|
||||
|
||||
|
||||
# ===================================================================== 数据采集
|
||||
def _db_news(codes, days=40, limit=8):
|
||||
"""按关联股票代码取新闻(近 days 天)"""
|
||||
if not codes:
|
||||
return []
|
||||
conds, args = [], []
|
||||
for c in codes:
|
||||
conds.append("(related_stocks=? OR related_stocks LIKE ? OR related_stocks LIKE ?)")
|
||||
args += [c, f"%,{c}", f"{c},%"]
|
||||
args.append(limit)
|
||||
return query(
|
||||
f"SELECT id,title,content,source,category,publish_date,sentiment FROM news "
|
||||
f"WHERE ({' OR '.join(conds)}) AND publish_date >= date('now','-{days} day') "
|
||||
f"ORDER BY publish_date DESC LIMIT ?", args)
|
||||
|
||||
|
||||
def _rag_news_text(question, where=None, top_k=4):
|
||||
"""向量语义检索,返回紧凑文本"""
|
||||
try:
|
||||
hits = query_vectors(question, n_results=top_k, where=where, name=CHROMA_NEWS_COLLECTION)
|
||||
out = []
|
||||
for h in hits:
|
||||
m = h.get("metadata", {})
|
||||
out.append(f" [{m.get('date','')}] {m.get('title','')} (情感{m.get('sentiment',0):+.2f}) "
|
||||
f"{h.get('document','')[:90]}")
|
||||
return out
|
||||
except Exception as e:
|
||||
log.warning("rag fail: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def _fmt_news(items):
|
||||
return "\n".join(
|
||||
f" [{n['publish_date']}] {n['title']} (情感{n['sentiment']:+.2f}) {n['content'][:90]}"
|
||||
for n in items) or " (暂无)"
|
||||
|
||||
|
||||
def collect_chain(code):
|
||||
"""采集个股 + 产业链各环节资讯,返回结构化 dict"""
|
||||
stock = query_one("SELECT * FROM stocks WHERE code=?", (code,))
|
||||
if not stock:
|
||||
return None
|
||||
chain = get_chain(stock["industry"])
|
||||
seg = {}
|
||||
|
||||
# 1. 个股直接动态
|
||||
direct = _db_news([code], days=40, limit=8)
|
||||
direct_rag = _rag_news_text(f"{stock['name']} 最新动态 业绩 公告 重大事项", where={"code": code}, top_k=4)
|
||||
seg["direct"] = {"db": _fmt_news(direct), "rag": "\n".join(direct_rag) or " (暂无)"}
|
||||
|
||||
# 2. 上游
|
||||
up_db = _db_news(chain["upstream"]["codes"], days=40, limit=6)
|
||||
up_rag = _rag_news_text(f"{stock['industry']} 上游 {chain['upstream']['keywords']}", top_k=4)
|
||||
seg["upstream"] = {"codes": chain["upstream"]["codes"],
|
||||
"keywords": chain["upstream"]["keywords"],
|
||||
"db": _fmt_news(up_db), "rag": "\n".join(up_rag) or " (暂无)"}
|
||||
|
||||
# 3. 下游
|
||||
dn_db = _db_news(chain["downstream"]["codes"], days=40, limit=6)
|
||||
dn_rag = _rag_news_text(f"{stock['industry']} 下游需求 景气 {chain['downstream']['keywords']}", top_k=4)
|
||||
seg["downstream"] = {"codes": chain["downstream"]["codes"],
|
||||
"keywords": chain["downstream"]["keywords"],
|
||||
"db": _fmt_news(dn_db), "rag": "\n".join(dn_rag) or " (暂无)"}
|
||||
|
||||
# 4. 同业
|
||||
peer_db = _db_news(chain["peers"], days=40, limit=5)
|
||||
peer_rag = _rag_news_text(f"{stock['industry']} 竞争格局 同业 {chain['peers']}", top_k=3)
|
||||
seg["peers"] = {"codes": chain["peers"],
|
||||
"db": _fmt_news(peer_db), "rag": "\n".join(peer_rag) or " (暂无)"}
|
||||
|
||||
# 5. 技术面 + 机构
|
||||
ind = compute_indicators(query("SELECT date,open,high,low,close,volume FROM stock_daily "
|
||||
"WHERE code=? ORDER BY date ASC", (code,)))
|
||||
ratings = query("SELECT inst_name, rating, target_price, rating_date FROM inst_ratings "
|
||||
"WHERE stock_code=? ORDER BY rating_date DESC LIMIT 5", (code,))
|
||||
holdings = query("SELECT inst_name, quarter, hold_value, change_pct FROM fund_holdings "
|
||||
"WHERE stock_code=? ORDER BY quarter DESC LIMIT 5", (code,))
|
||||
seg["stock"] = stock
|
||||
seg["chain"] = chain
|
||||
seg["indicators"] = ind
|
||||
seg["ratings"] = ratings
|
||||
seg["holdings"] = holdings
|
||||
return seg
|
||||
|
||||
|
||||
# ===================================================================== 分析提示词
|
||||
def _fmt_ind(ind):
|
||||
return (f"最新价 {ind.get('close')}({ind.get('change_pct',0):+.2f}%),5日{ind.get('chg_5d',0):+.2f}% / "
|
||||
f"20日{ind.get('chg_20d',0):+.2f}%,RSI={ind.get('rsi')},量比{ind.get('vol_ratio')},"
|
||||
f"MA20={ind.get('ma20')}")
|
||||
|
||||
|
||||
def build_prompt(seg):
|
||||
s = seg["stock"]
|
||||
chain = seg["chain"]
|
||||
rated = "、".join(f"{r['inst_name']}({r['rating']},目标{r['target_price']})" for r in seg["ratings"]) or "暂无"
|
||||
held = ";".join(f"{h['inst_name']} {h['quarter']}持仓{h['hold_value']:.0f}万 环比{h['change_pct']:+.1f}%"
|
||||
for h in seg["holdings"]) or "暂无"
|
||||
return f"""你是资深产业链跟踪分析师,正在对【持仓标的】{s['name']}({s['code']}) 进行深度跟踪。请综合【个股】与【产业链上下游/同业】的全部动态,输出一份专业、有洞察的产业链跟踪分析。
|
||||
|
||||
【个股基本面】
|
||||
{s.get('description','')}
|
||||
|
||||
【技术面】{_fmt_ind(seg['indicators'])}
|
||||
【机构动向】评级:{rated} | 持仓:{held}
|
||||
|
||||
【一、个股直接动态】(公告/新闻/机构观点)
|
||||
{seg['direct']['db']}
|
||||
{seg['direct']['rag']}
|
||||
|
||||
【二、上游产业链】(供给/原材料/成本端;关联股票 {seg['upstream']['codes'] or '无'},关键词:{seg['upstream']['keywords']})
|
||||
{seg['upstream']['db']}
|
||||
{seg['upstream']['rag']}
|
||||
|
||||
【三、下游产业链】(需求/客户/景气端;关联股票 {seg['downstream']['codes'] or '无'},关键词:{seg['downstream']['keywords']})
|
||||
{seg['downstream']['db']}
|
||||
{seg['downstream']['rag']}
|
||||
|
||||
【四、同业动态】(竞争格局;{seg['peers']['codes'] or '无'})
|
||||
{seg['peers']['db']}
|
||||
{seg['peers']['rag']}
|
||||
|
||||
【输出要求】
|
||||
第一步,先输出一个 json 代码块(必须最先输出,内容为本次判定,不要包含其他内容):
|
||||
```json
|
||||
{{"significance":"high|medium|low","impact_score":0到100的整数,"change_kind":"利好/利空/中性/震荡","summary":"一句话总结","chain_trend":"产业链趋势判断"}}
|
||||
```
|
||||
第二步,再输出 Markdown 分析报告,结构如下:
|
||||
## 一、个股最新动态
|
||||
## 二、上游产业链分析(供给、原材料、成本端变化及其传导)
|
||||
## 三、下游产业链分析(需求、客户、景气度变化及其传导)
|
||||
## 四、同行业竞争格局
|
||||
## 五、产业链传导与投资启示(上游→中游→下游,对{ s['name']}的影响路径)
|
||||
## 六、风险提示
|
||||
## 关注要点(3-5条)
|
||||
|
||||
分析须严格基于提供的资讯,避免编造。impact_score 反映本次跟踪发现的动态对股价的潜在影响程度:>=65 视为重大变化。"""
|
||||
|
||||
|
||||
def parse_judge(text):
|
||||
"""从容错地从 LLM 输出中提取 JSON 判定(支持 json 代码块/截断/缺失字段)"""
|
||||
t = text.strip()
|
||||
# 0) 优先取 json 代码块
|
||||
m = re.search(r"```json\s*(.*?)\s*```", t, re.S)
|
||||
if m:
|
||||
try:
|
||||
j = json.loads(m.group(1))
|
||||
if "significance" in j or "impact_score" in j:
|
||||
return j
|
||||
except Exception:
|
||||
pass
|
||||
# 1) 整体 JSON 对象解析
|
||||
for m in re.finditer(r"\{[^{}]*\}", t, re.S):
|
||||
try:
|
||||
j = json.loads(m.group(0))
|
||||
if "significance" in j or "impact_score" in j:
|
||||
return j
|
||||
except Exception:
|
||||
continue
|
||||
# 2) 逐字段容错提取(末尾被截断时也能拿到已输出字段)
|
||||
out = {}
|
||||
m = re.search(r'"significance"\s*:\s*"(high|medium|low)"', t)
|
||||
if m:
|
||||
out["significance"] = m.group(1)
|
||||
m = re.search(r'"impact_score"\s*:\s*(\d+)', t)
|
||||
if m:
|
||||
out["impact_score"] = int(m.group(1))
|
||||
m = re.search(r'"change_kind"\s*:\s*"([^"]{1,20})"', t)
|
||||
if m:
|
||||
out["change_kind"] = m.group(1)
|
||||
m = re.search(r'"summary"\s*:\s*"((?:[^"\\]|\\.){1,200})"', t)
|
||||
if m:
|
||||
out["summary"] = m.group(1)
|
||||
m = re.search(r'"chain_trend"\s*:\s*"((?:[^"\\]|\\.){1,120})"', t)
|
||||
if m:
|
||||
out["chain_trend"] = m.group(1)
|
||||
return out or None
|
||||
|
||||
|
||||
def strip_json_block(text):
|
||||
"""从报告文本中剥离最前面的 json 代码块,保留纯 Markdown"""
|
||||
m = re.search(r"```json\s*.*?```\s*", text, re.S)
|
||||
if m:
|
||||
return text[m.end():].strip()
|
||||
return text
|
||||
|
||||
|
||||
# ===================================================================== 执行
|
||||
def track_stock(code, focus=""):
|
||||
"""执行一次跟踪,返回 {ok, report_id, meta, ...}"""
|
||||
with _track_lock:
|
||||
seg = collect_chain(code)
|
||||
if not seg:
|
||||
return {"error": "股票不存在"}
|
||||
s = seg["stock"]
|
||||
prompt = build_prompt(seg)
|
||||
try:
|
||||
reply = llm_chat([
|
||||
{"role": "system", "content": "你是一名严谨专业的产业链跟踪分析师,输出结构化、有数据支撑的分析。"},
|
||||
{"role": "user", "content": prompt},
|
||||
]).strip()
|
||||
if not reply:
|
||||
raise RuntimeError("LLM 返回为空")
|
||||
judge = parse_judge(reply)
|
||||
# 摘要兜底:解析不到则取报告首个标题;并剥离 json 块保留纯净 Markdown
|
||||
clean_report = strip_json_block(reply)
|
||||
summary = (judge or {}).get("summary") or _first_heading(clean_report)
|
||||
meta = {
|
||||
"significance": (judge or {}).get("significance", "medium"),
|
||||
"impact_score": int((judge or {}).get("impact_score", 50)),
|
||||
"change_kind": (judge or {}).get("change_kind", "中性"),
|
||||
"summary": summary,
|
||||
"chain_trend": (judge or {}).get("chain_trend", ""),
|
||||
"news_counts": {
|
||||
"direct": _count(seg["direct"]),
|
||||
"upstream": _count(seg["upstream"]),
|
||||
"downstream": _count(seg["downstream"]),
|
||||
"peers": _count(seg["peers"]),
|
||||
},
|
||||
"focus": focus,
|
||||
}
|
||||
sources = {
|
||||
"direct": seg["direct"], "upstream": seg["upstream"],
|
||||
"downstream": seg["downstream"], "peers": seg["peers"],
|
||||
"indicators": _fmt_ind(seg["indicators"]),
|
||||
"ratings": seg["ratings"], "holdings": seg["holdings"],
|
||||
}
|
||||
execute(
|
||||
"INSERT INTO tracking_reports(code, stock_name, industry, report, meta, sources, status, created_at) "
|
||||
"VALUES(?,?,?,?,?,?,'done',datetime('now','localtime'))",
|
||||
(code, s["name"], s["industry"], clean_report, json.dumps(meta, ensure_ascii=False),
|
||||
json.dumps(sources, ensure_ascii=False)))
|
||||
rid = query_one("SELECT MAX(id) id FROM tracking_reports")["id"]
|
||||
_notify_if_significant(rid, s, meta)
|
||||
return {"ok": True, "report_id": rid, "meta": meta}
|
||||
except Exception as e:
|
||||
log.exception("track %s fail", code)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _count(seg):
|
||||
return (seg["db"].count("[") + seg["rag"].count("[")) // 1
|
||||
|
||||
|
||||
def _first_heading(text):
|
||||
"""取报告第一行非空文本作为摘要兜底"""
|
||||
for line in (text or "").splitlines():
|
||||
line = line.strip().lstrip("#* ").strip()
|
||||
if line:
|
||||
return line[:60]
|
||||
return ""
|
||||
|
||||
|
||||
def _notify_if_significant(rid, stock, meta):
|
||||
"""影响度达阈值且开启通知 → 邮件"""
|
||||
cfg = tracking_config()
|
||||
try:
|
||||
if cfg["notify"] and int(meta["impact_score"]) >= int(cfg["impact_threshold"]):
|
||||
from engine.notifier import send_email
|
||||
mc = mail_config()
|
||||
send_email(
|
||||
f"[持仓跟踪] {stock['name']} 出现{meta.get('change_kind','')}动态(影响度{meta['impact_score']})",
|
||||
f"""<html><body style="font-family:Microsoft YaHei;padding:20px;background:#f5f6f8;">
|
||||
<div style="max-width:640px;margin:auto;background:#fff;border-radius:8px;border:1px solid #e5e7eb;overflow:hidden;">
|
||||
<div style="background:#1e293b;color:#fff;padding:14px 20px;font-size:17px;font-weight:bold;">🧭 持仓跟踪 · {stock['name']}({stock['code']})</div>
|
||||
<div style="padding:16px 20px;">
|
||||
<p><b>影响度:</b>{meta['impact_score']}/100({'🔴 重大' if meta['impact_score']>=65 else '🟡 关注'})<br>
|
||||
<b>性质:</b>{meta.get('change_kind','')} | <b>显著性:</b>{meta.get('significance','')}</p>
|
||||
<p style="font-size:15px;"><b>摘要:</b>{meta.get('summary','')}</p>
|
||||
<p style="color:#555;"><b>产业链趋势:</b>{meta.get('chain_trend','')}</p>
|
||||
<p style="color:#888;font-size:12px;">个股资讯 {meta['news_counts']['direct']} 条 / 上游 {meta['news_counts']['upstream']} 条 / 下游 {meta['news_counts']['downstream']} 条 / 同业 {meta['news_counts']['peers']} 条</p>
|
||||
</div></div></body></html>""",
|
||||
cfg=mc)
|
||||
set_tracking_state(last_alert=int(meta["impact_score"]))
|
||||
except Exception as e:
|
||||
log.warning("track notify fail: %s", e)
|
||||
|
||||
|
||||
# ===================================================================== 批量与调度
|
||||
def track_watchlist(progress=None):
|
||||
"""串行跟踪自选股(持仓)。同一时刻只允许一个跟踪任务(防重复)"""
|
||||
global _cycle_running
|
||||
if _cycle_running:
|
||||
return {"tracked": 0, "msg": "已有跟踪任务进行中,请稍后再试"}
|
||||
_cycle_running = True
|
||||
try:
|
||||
stocks = query("SELECT w.code, s.name FROM watchlist w JOIN stocks s ON s.code=w.code ORDER BY w.added_at")
|
||||
if not stocks:
|
||||
return {"tracked": 0, "msg": "自选股为空,请先在股票池添加"}
|
||||
results = []
|
||||
for i, st in enumerate(stocks):
|
||||
r = track_stock(st["code"])
|
||||
results.append({"code": st["code"], "name": st["name"], **r})
|
||||
set_tracking_state(last_run=time.strftime("%Y-%m-%d %H:%M:%S"), last_stock=st["name"])
|
||||
if progress:
|
||||
progress(i + 1, len(stocks))
|
||||
return {"tracked": len(results), "results": results}
|
||||
finally:
|
||||
_cycle_running = False
|
||||
|
||||
|
||||
def latest_reports(code, limit=5):
|
||||
return query("SELECT id, code, stock_name, industry, meta, status, created_at "
|
||||
"FROM tracking_reports WHERE code=? ORDER BY id DESC LIMIT ?", (code, limit))
|
||||
|
||||
|
||||
def list_reports(limit=30):
|
||||
return query("SELECT id, code, stock_name, industry, meta, status, created_at "
|
||||
"FROM tracking_reports ORDER BY id DESC LIMIT ?", (limit,))
|
||||
|
||||
|
||||
def get_report(rid):
|
||||
return query_one("SELECT * FROM tracking_reports WHERE id=?", (rid,))
|
||||
|
||||
|
||||
class TrackingThread(threading.Thread):
|
||||
"""后台调度:定期跟踪全部持仓股票"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(daemon=True, name="tracking")
|
||||
self._stop = threading.Event()
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
|
||||
def run(self):
|
||||
log.info("持仓跟踪调度器启动")
|
||||
first = True
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
cfg = tracking_config()
|
||||
if first:
|
||||
# 启动后等待一个完整间隔再首跑,避免重启即烧一轮 LLM、与手动操作冲突
|
||||
self._stop.wait(cfg.get("interval_min", 60) * 60)
|
||||
first = False
|
||||
continue
|
||||
if cfg["enabled"]:
|
||||
try:
|
||||
r = track_watchlist()
|
||||
log.info("tracking cycle: %s", r)
|
||||
except Exception as e:
|
||||
log.warning("tracking cycle error: %s", e)
|
||||
except Exception as e:
|
||||
log.warning("tracking loop error: %s", e)
|
||||
self._stop.wait(cfg.get("interval_min", 60) * 60)
|
||||
log.info("持仓跟踪调度器停止")
|
||||
|
||||
|
||||
_tracking = None
|
||||
|
||||
|
||||
def start_tracking():
|
||||
global _tracking
|
||||
if _tracking and _tracking.is_alive():
|
||||
return _tracking
|
||||
_tracking = TrackingThread()
|
||||
_tracking.start()
|
||||
return _tracking
|
||||
@@ -0,0 +1,219 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
产业链知识库:行业 → 上游/下游/同业 + 检索关键词
|
||||
用于「持仓跟踪智能体」:跟踪某股票时,除个股动态外,还深度覆盖其
|
||||
上游(供给/成本)、下游(需求/客户)、同业竞争链的动态。
|
||||
|
||||
- codes:引用本系统股票池内的代码(便于 RAG 按 code 过滤 + 关联查询)
|
||||
- keywords:行业/环节检索词(用于向量语义检索)
|
||||
后期接入真实数据时,可在此补充真实产业链公司代码与关键词。
|
||||
"""
|
||||
INDUSTRY_CHAIN = {
|
||||
"白酒": {
|
||||
"upstream": {"codes": [], "keywords": "高粱,小麦,粮食,基酒,包装,玻璃瓶,酿酒"},
|
||||
"downstream": {"codes": ["603288"], "keywords": "白酒消费,宴席,商务宴请,经销商,渠道,动销,开瓶"},
|
||||
"peers": ["000858", "600809", "000568"],
|
||||
"keywords": "白酒,茅台,五粮液,提价,批价,库存,动销,高端白酒"},
|
||||
"动力电池": {
|
||||
"upstream": {"codes": ["002460", "002648", "601899"], "keywords": "锂盐,碳酸锂,电解液,正极,负极,隔膜,铜箔,锂矿,六氟磷酸锂"},
|
||||
"downstream": {"codes": ["002594", "601633", "000625", "601238", "600104"], "keywords": "新能源车销量,装车量,动力电池装机,储能需求,电池排产"},
|
||||
"peers": [],
|
||||
"keywords": "动力电池,宁德时代,电池装机,储能,电池价格,产能利用率"},
|
||||
"新能源汽车": {
|
||||
"upstream": {"codes": ["300750", "002460", "601899"], "keywords": "动力电池,锂,芯片,钢材,铝,智能驾驶硬件"},
|
||||
"downstream": {"codes": [], "keywords": "新能源车销量,渗透率,出口,充电桩,以旧换新,价格战"},
|
||||
"peers": ["601633", "000625", "601238", "600104"],
|
||||
"keywords": "新能源汽车,比亚迪,整车,销量,智能驾驶,渗透率"},
|
||||
"光伏": {
|
||||
"upstream": {"codes": ["601600", "600309"], "keywords": "硅料,硅片,银浆,玻璃,铝框,石英砂,多晶硅"},
|
||||
"downstream": {"codes": ["300274", "600900"], "keywords": "装机量,组件招标,分布式,集中式,电网,海外订单"},
|
||||
"peers": ["600438", "601012"],
|
||||
"keywords": "光伏,组件,硅料价格,逆变器,装机,产能出清"},
|
||||
"光伏储能": {
|
||||
"upstream": {"codes": ["601012", "600438", "300750"], "keywords": "组件,硅料,储能电芯,逆变器材料"},
|
||||
"downstream": {"codes": ["600900"], "keywords": "储能装机,大储,海外储能,电网侧,工商业储能"},
|
||||
"peers": [],
|
||||
"keywords": "光伏储能,阳光电源,储能系统,逆变器,出货量"},
|
||||
"锂电材料": {
|
||||
"upstream": {"codes": ["601899"], "keywords": "锂矿,锂精矿,盐湖提锂,资源"},
|
||||
"downstream": {"codes": ["300750"], "keywords": "电池排产,锂盐需求,三元,磷酸铁锂,正极材料"},
|
||||
"peers": [],
|
||||
"keywords": "锂电材料,赣锋锂业,碳酸锂价格,锂盐,氢氧化锂"},
|
||||
"半导体": {
|
||||
"upstream": {"codes": ["688012", "002371"], "keywords": "晶圆,光刻,刻蚀,硅片,封装,EDA,靶材"},
|
||||
"downstream": {"codes": ["688041", "002475", "002241", "000725", "000063"], "keywords": "代工订单,产能利用率,下游需求,消费电子复苏,先进制程"},
|
||||
"peers": ["603986", "603501"],
|
||||
"keywords": "半导体,中芯国际,晶圆代工,产能,国产替代,芯片"},
|
||||
"半导体设备": {
|
||||
"upstream": {"codes": [], "keywords": "零部件,气体,靶材,精密机械,射频电源"},
|
||||
"downstream": {"codes": ["688981", "688041"], "keywords": "晶圆厂扩产,资本开支,设备招标,先进制程"},
|
||||
"peers": ["688012", "002371"],
|
||||
"keywords": "半导体设备,北方华创,中微公司,刻蚀,薄膜沉积,国产化率"},
|
||||
"芯片设计": {
|
||||
"upstream": {"codes": ["688981"], "keywords": "流片,先进制程,IP授权,代工"},
|
||||
"downstream": {"codes": ["002230"], "keywords": "服务器,信创,国产替代,算力,数据中心"},
|
||||
"peers": ["603986", "603501"],
|
||||
"keywords": "芯片设计,海光信息,CPU,DCU,算力,国产CPU"},
|
||||
"创新药": {
|
||||
"upstream": {"codes": ["603259"], "keywords": "临床,原料药,API,专利,生物药"},
|
||||
"downstream": {"codes": [], "keywords": "医保谈判,集采,销售放量,适应症,出海授权"},
|
||||
"peers": [],
|
||||
"keywords": "创新药,恒瑞医药,临床,获批,医保,license-out"},
|
||||
"CXO": {
|
||||
"upstream": {"codes": [], "keywords": "原料药,试剂,实验设备,动物实验"},
|
||||
"downstream": {"codes": ["600276"], "keywords": "订单,新签,产能,海外需求,生物医药融资"},
|
||||
"peers": [],
|
||||
"keywords": "CXO,药明康德,新签订单,产能利用率,生物医药,研发外包"},
|
||||
"医疗器械": {
|
||||
"upstream": {"codes": [], "keywords": "电子元器件,材料,芯片,传感器"},
|
||||
"downstream": {"codes": ["300015"], "keywords": "集采,医疗设备更新,政府采购,出海,装机"},
|
||||
"peers": [],
|
||||
"keywords": "医疗器械,迈瑞医疗,集采,设备更新,海外,装机量"},
|
||||
"医疗服务": {
|
||||
"upstream": {"codes": ["300760"], "keywords": "设备采购,耗材"},
|
||||
"downstream": {"codes": [], "keywords": "就诊量,消费医疗,医保,屈光,白内障"},
|
||||
"peers": [],
|
||||
"keywords": "医疗服务,爱尔眼科,就诊量,消费医疗,连锁"},
|
||||
"中药": {
|
||||
"upstream": {"codes": [], "keywords": "中药材,种植,提价,药材价格"},
|
||||
"downstream": {"codes": [], "keywords": "医保,OTC,养生需求,品牌,渠道"},
|
||||
"peers": ["000538", "600085"],
|
||||
"keywords": "中药,同仁堂,云南白药,药材价格,品牌,医保"},
|
||||
"银行": {
|
||||
"upstream": {"codes": [], "keywords": "存款,资金成本,同业,负债端"},
|
||||
"downstream": {"codes": ["000002", "600048"], "keywords": "信贷需求,净息差,不良率,零售,对公"},
|
||||
"peers": ["601398", "601166", "600000"],
|
||||
"keywords": "银行,净息差,信贷,存款,不良率,息差收窄"},
|
||||
"券商": {
|
||||
"upstream": {"codes": [], "keywords": "市场成交,两融,自营,利率"},
|
||||
"downstream": {"codes": ["300059", "300033"], "keywords": "市场成交额,IPO,经纪,投行,并购重组"},
|
||||
"peers": ["601688", "600999"],
|
||||
"keywords": "券商,中信证券,市场成交,投行,经纪,两融"},
|
||||
"互联网金融": {
|
||||
"upstream": {"codes": [], "keywords": "市场成交,基金,代销牌照"},
|
||||
"downstream": {"codes": ["300033"], "keywords": "市场成交额,基金销售,代销,用户数,财富管理"},
|
||||
"peers": ["300033"],
|
||||
"keywords": "互联网金融,东方财富,基金销售,代销,市场成交,财富管理"},
|
||||
"金融科技": {
|
||||
"upstream": {"codes": [], "keywords": "行情数据,云计算,AI大模型"},
|
||||
"downstream": {"codes": ["600036"], "keywords": "金融机构IT采购,量化交易,财富管理数字化"},
|
||||
"peers": ["300059"],
|
||||
"keywords": "金融科技,同花顺,行情,量化,金融机构IT,AI"},
|
||||
"保险": {
|
||||
"upstream": {"codes": [], "keywords": "利率,债券,投资收益,准备金"},
|
||||
"downstream": {"codes": [], "keywords": "保费,新业务价值,代理人,银保渠道,理赔,寿险"},
|
||||
"peers": ["601628", "601318"],
|
||||
"keywords": "保险,中国平安,保费,新业务价值,投资收益率,寿险"},
|
||||
"汽车": {
|
||||
"upstream": {"codes": ["300750", "002475", "601600"], "keywords": "电池,芯片,钢材,压铸,汽车电子"},
|
||||
"downstream": {"codes": [], "keywords": "汽车销量,新能源渗透率,出口,以旧换新,价格战"},
|
||||
"peers": ["000625", "601238", "600104", "002594"],
|
||||
"keywords": "汽车,长城汽车,销量,出口,新能源,智能驾驶"},
|
||||
"消费电子": {
|
||||
"upstream": {"codes": ["603501", "000725"], "keywords": "芯片,CIS,显示,PCB,声学,射频"},
|
||||
"downstream": {"codes": [], "keywords": "手机出货,AI终端,可穿戴,VR/AR,苹果链,折叠屏"},
|
||||
"peers": ["002241", "002475"],
|
||||
"keywords": "消费电子,立讯精密,歌尔,苹果链,AI终端,可穿戴"},
|
||||
"面板": {
|
||||
"upstream": {"codes": [], "keywords": "玻璃基板,偏光片,驱动IC,蒸镀"},
|
||||
"downstream": {"codes": ["002475"], "keywords": "面板价格,稼动率,TV,OLED,手机需求"},
|
||||
"peers": ["000100", "000725"],
|
||||
"keywords": "面板,京东方,TCL,面板价格,稼动率,OLED"},
|
||||
"家电": {
|
||||
"upstream": {"codes": ["600309"], "keywords": "铜,铝,压缩机,芯片,钢材"},
|
||||
"downstream": {"codes": [], "keywords": "空调排产,以旧换新,家电补贴,海外需求,地产竣工"},
|
||||
"peers": ["000651", "600690", "000333"],
|
||||
"keywords": "家电,美的,格力,海尔,空调排产,以旧换新,海外"},
|
||||
"化工": {
|
||||
"upstream": {"codes": [], "keywords": "原油,煤炭,天然气,MDI原料,纯苯"},
|
||||
"downstream": {"codes": ["000333", "600048"], "keywords": "MDI价格,聚氨酯需求,涂料,家电排产,地产需求"},
|
||||
"peers": ["002648", "600309"],
|
||||
"keywords": "化工,万华化学,卫星化学,MDI,聚氨酯,景气度"},
|
||||
"有色": {
|
||||
"upstream": {"codes": [], "keywords": "铜矿,金价,铝土矿,能源,品位"},
|
||||
"downstream": {"codes": ["300750", "002594", "601012"], "keywords": "铜价,铝价,金价,库存,新能源需求,电网"},
|
||||
"peers": ["601600", "601899"],
|
||||
"keywords": "有色,紫金矿业,铜价,金价,铝价,库存"},
|
||||
"工程机械": {
|
||||
"upstream": {"codes": [], "keywords": "钢材,液压件,发动机,轴承"},
|
||||
"downstream": {"codes": ["000002"], "keywords": "挖掘机销量,基建投资,地产开工,出口,更新周期"},
|
||||
"peers": ["000425", "600031"],
|
||||
"keywords": "工程机械,三一重工,徐工,挖掘机销量,基建,出口"},
|
||||
"军工": {
|
||||
"upstream": {"codes": [], "keywords": "钛合金,碳纤维,航发,军机零部件"},
|
||||
"downstream": {"codes": [], "keywords": "军费,军贸,订单,列装,航空发动机"},
|
||||
"peers": [],
|
||||
"keywords": "军工,中航沈飞,军贸,订单,航空,军费"},
|
||||
"房地产": {
|
||||
"upstream": {"codes": ["600036", "601166"], "keywords": "土地,建材,融资,按揭"},
|
||||
"downstream": {"codes": ["000333", "000651", "600690"], "keywords": "销售,竣工,政策,保交楼,土拍,去库存"},
|
||||
"peers": ["600048", "000002"],
|
||||
"keywords": "房地产,万科,保利,销售,政策,保交楼,土拍"},
|
||||
"食品饮料": {
|
||||
"upstream": {"codes": ["002714", "300498"], "keywords": "原奶,生猪,大豆,包装,原材料"},
|
||||
"downstream": {"codes": [], "keywords": "消费复苏,提价,渠道,餐饮,库存"},
|
||||
"peers": ["600519"],
|
||||
"keywords": "食品饮料,海天,伊利,双汇,提价,消费复苏,渠道"},
|
||||
"养殖": {
|
||||
"upstream": {"codes": [], "keywords": "饲料,玉米,豆粕,兽药,仔猪"},
|
||||
"downstream": {"codes": ["000895"], "keywords": "猪价,能繁母猪,出栏量,冻肉库存,生猪期货"},
|
||||
"peers": ["300498", "002714"],
|
||||
"keywords": "养殖,牧原,温氏,猪价,能繁母猪,出栏"},
|
||||
"安防": {
|
||||
"upstream": {"codes": ["603501", "000725"], "keywords": "芯片,CIS,传感器,镜头"},
|
||||
"downstream": {"codes": ["002230"], "keywords": "政府项目,数字化转型,海外安防,AI大模型,toB"},
|
||||
"peers": [],
|
||||
"keywords": "安防,海康威视,大华,政府项目,海外,AIoT"},
|
||||
"人工智能": {
|
||||
"upstream": {"codes": ["688041", "603986"], "keywords": "算力,GPU,大模型训练,芯片"},
|
||||
"downstream": {"codes": ["688111", "300033"], "keywords": "大模型落地,教育,医疗,toB订单,应用"},
|
||||
"peers": ["002415"],
|
||||
"keywords": "人工智能,科大讯飞,大模型,算力,toB,语音"},
|
||||
"软件": {
|
||||
"upstream": {"codes": ["002230", "688041"], "keywords": "算力,云,国产化,AI能力"},
|
||||
"downstream": {"codes": ["300033", "600036"], "keywords": "信创,订阅,付费用户,金融IT支出,国产替代"},
|
||||
"peers": ["600570", "688111"],
|
||||
"keywords": "软件,金山办公,恒生电子,信创,订阅,金融IT"},
|
||||
"通信设备": {
|
||||
"upstream": {"codes": ["688981", "688041"], "keywords": "芯片,基带,射频,光模块"},
|
||||
"downstream": {"codes": ["600941"], "keywords": "5G建设,运营商资本开支,算力网络,服务器,集采"},
|
||||
"peers": [],
|
||||
"keywords": "通信设备,中兴通讯,5G,光模块,算力,运营商集采"},
|
||||
"通信运营": {
|
||||
"upstream": {"codes": ["000063"], "keywords": "设备集采,5G建设,光缆"},
|
||||
"downstream": {"codes": [], "keywords": "移动用户,ARPU,算力,云业务,数字经济,AI"},
|
||||
"peers": [],
|
||||
"keywords": "通信运营,中国移动,5G,ARPU,算力,云"},
|
||||
"免税": {
|
||||
"upstream": {"codes": [], "keywords": "奢侈品,品牌商,机场租金,供货"},
|
||||
"downstream": {"codes": [], "keywords": "免税销售,出入境客流,离岛免税,消费复苏,口岸"},
|
||||
"peers": [],
|
||||
"keywords": "免税,中国中免,离岛免税,出入境,客流"},
|
||||
"电力": {
|
||||
"upstream": {"codes": [], "keywords": "来水,煤价,发电设备,水电"},
|
||||
"downstream": {"codes": ["601600"], "keywords": "用电量,绿电,电价,储能,高耗能"},
|
||||
"peers": [],
|
||||
"keywords": "电力,长江电力,水电,来水,电价,用电量"},
|
||||
"煤炭": {
|
||||
"upstream": {"codes": [], "keywords": "煤矿,安全生产,产能"},
|
||||
"downstream": {"codes": ["600900", "600309"], "keywords": "煤价,长协,库存,发电需求,火电"},
|
||||
"peers": [],
|
||||
"keywords": "煤炭,中国神华,煤价,长协,库存,火电"},
|
||||
"稀土": {
|
||||
"upstream": {"codes": [], "keywords": "稀土矿,离子型,配额,冶炼"},
|
||||
"downstream": {"codes": ["002594", "002475"], "keywords": "稀土价格,磁材,出口管制,新能源需求,电机"},
|
||||
"peers": [],
|
||||
"keywords": "稀土,北方稀土,氧化镨钕,磁材,价格,配额"},
|
||||
}
|
||||
|
||||
# 无明确产业链的兜底
|
||||
FALLBACK_CHAIN = {
|
||||
"upstream": {"codes": [], "keywords": "原材料,供应链,成本"},
|
||||
"downstream": {"codes": [], "keywords": "需求,客户,景气度"},
|
||||
"peers": [],
|
||||
"keywords": "行业动态,景气度,政策",
|
||||
}
|
||||
|
||||
|
||||
def get_chain(industry):
|
||||
return INDUSTRY_CHAIN.get(industry) or FALLBACK_CHAIN
|
||||
+28
-1
@@ -6,7 +6,7 @@
|
||||
"""
|
||||
from database import query, execute, query_one
|
||||
from config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_MAX_TOKENS, \
|
||||
LLM_TEMPERATURE, LLM_TIMEOUT, MAIL_DEFAULTS, MONITOR_DEFAULTS
|
||||
LLM_TEMPERATURE, LLM_TIMEOUT, MAIL_DEFAULTS, MONITOR_DEFAULTS, TRACKING_DEFAULTS
|
||||
|
||||
|
||||
def get_setting(key, default=""):
|
||||
@@ -92,3 +92,30 @@ def set_monitor_state(last_news_id=None, last_scan=None, last_sent=None):
|
||||
set_setting("monitor_last_scan", last_scan)
|
||||
if last_sent is not None:
|
||||
set_setting("monitor_last_sent", last_sent)
|
||||
|
||||
|
||||
# ===================================================================== 持仓跟踪
|
||||
def tracking_config():
|
||||
return {
|
||||
"enabled": get_setting("tracking_enabled", TRACKING_DEFAULTS["tracking_enabled"]) == "1",
|
||||
"interval_min": max(15, int(get_setting("tracking_interval", TRACKING_DEFAULTS["tracking_interval"]))),
|
||||
"notify": get_setting("tracking_notify", TRACKING_DEFAULTS["tracking_notify"]) == "1",
|
||||
"impact_threshold": float(get_setting("tracking_impact_threshold", TRACKING_DEFAULTS["tracking_impact_threshold"])),
|
||||
}
|
||||
|
||||
|
||||
def tracking_state():
|
||||
return {
|
||||
"last_run": get_setting("tracking_last_run", ""),
|
||||
"last_stock": get_setting("tracking_last_stock", ""),
|
||||
"last_alert": int(get_setting("tracking_last_alert", "0")),
|
||||
}
|
||||
|
||||
|
||||
def set_tracking_state(last_run=None, last_stock=None, last_alert=None):
|
||||
if last_run is not None:
|
||||
set_setting("tracking_last_run", last_run)
|
||||
if last_stock is not None:
|
||||
set_setting("tracking_last_stock", last_stock)
|
||||
if last_alert is not None:
|
||||
set_setting("tracking_last_alert", last_alert)
|
||||
@@ -25,6 +25,13 @@ async function loadSettings() {
|
||||
const cats = mono.categories || [];
|
||||
$$('#monitorCats input').forEach(c => c.checked = cats.includes(c.value));
|
||||
renderState(curSettings.state);
|
||||
// 持仓跟踪
|
||||
const tr = curSettings.tracking || {};
|
||||
$('#trackingEnabled').checked = !!tr.enabled;
|
||||
$('#trackingInterval').value = tr.interval_min;
|
||||
$('#trackingImpact').value = tr.impact_threshold;
|
||||
$('#trackingNotify').checked = !!tr.notify;
|
||||
renderTrackingState(tr);
|
||||
loadLog();
|
||||
} catch (e) {
|
||||
toast('设置加载失败');
|
||||
@@ -38,6 +45,13 @@ function renderState(state) {
|
||||
<div class="mini-stat"><div class="ms-label">上次通知</div><div class="ms-value num">${state.last_sent_count || 0} 条</div></div>`;
|
||||
}
|
||||
|
||||
function renderTrackingState(tr) {
|
||||
$('#trackingState').innerHTML = `
|
||||
<div class="mini-stat"><div class="ms-label">上次运行</div><div class="ms-value">${tr.last_run || '—'}</div></div>
|
||||
<div class="mini-stat"><div class="ms-label">最近跟踪</div><div class="ms-value">${tr.last_stock || '—'}</div></div>
|
||||
<div class="mini-stat"><div class="ms-label">最近告警影响度</div><div class="ms-value num">${tr.last_alert ? tr.last_alert + '/100' : '—'}</div></div>`;
|
||||
}
|
||||
|
||||
function collectMail() {
|
||||
return {
|
||||
smtp_host: $('#smtpHost').value.trim(), smtp_port: $('#smtpPort').value,
|
||||
@@ -69,6 +83,12 @@ async function save(which) {
|
||||
llm_model: $('#llmModel').value.trim()
|
||||
};
|
||||
if (which === 'monitor') body.monitor = collectMonitor();
|
||||
if (which === 'tracking') body.tracking = {
|
||||
tracking_enabled: $('#trackingEnabled').checked,
|
||||
tracking_interval: $('#trackingInterval').value,
|
||||
tracking_impact_threshold: $('#trackingImpact').value,
|
||||
tracking_notify: $('#trackingNotify').checked
|
||||
};
|
||||
const r = await api('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
toast(r.msg || '已保存');
|
||||
} catch (e) { toast('保存失败:' + e.message); }
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/* 持仓跟踪智能体 */
|
||||
async function load() {
|
||||
try {
|
||||
const d = await api('/api/tracking');
|
||||
const items = d.items || [];
|
||||
renderRecords(items);
|
||||
loadPositions(items);
|
||||
} catch (e) {
|
||||
$('#recTb').innerHTML = '<tr><td colspan="7" class="empty">加载失败</td></tr>';
|
||||
}
|
||||
loadState();
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
api('/api/settings').then(d => {
|
||||
const t = d.tracking || {};
|
||||
$('#trackState').innerHTML = `
|
||||
<div class="kpi"><div class="kpi-label">自动跟踪</div><div class="kpi-value" style="font-size:15px">${t.enabled ? '✅ 开启' : '⏸ 关闭'}<span style="color:var(--muted);font-size:12px">(每 ${t.interval_min} 分钟)</span></div></div>
|
||||
<div class="kpi"><div class="kpi-label">上次运行</div><div class="kpi-value" style="font-size:15px">${t.last_run || '—'}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">最近跟踪标的</div><div class="kpi-value" style="font-size:15px">${t.last_stock || '—'}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">最近告警影响度</div><div class="kpi-value" style="font-size:15px">${t.last_alert ? t.last_alert + '/100' : '—'}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">重大判定阈值</div><div class="kpi-value" style="font-size:15px">≥ ${t.impact_threshold}</div></div>`;
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function loadPositions(all) {
|
||||
try {
|
||||
const w = await api('/api/watchlist');
|
||||
const pos = w.items || [];
|
||||
if (!pos.length) {
|
||||
$('#posBox').innerHTML = '<div class="empty">自选股为空。请先在 <a href="/stocks">股票池</a> 或个股详情页 ⭐ 添加持仓/关注标的</div>';
|
||||
return;
|
||||
}
|
||||
// 每只自选股取最近一条跟踪
|
||||
const rows = [];
|
||||
for (const p of pos) {
|
||||
const latest = all.find(r => r.code === p.code);
|
||||
rows.push({ stock: p, latest });
|
||||
}
|
||||
$('#posBox').innerHTML = `<table>
|
||||
<tr><th>股票</th><th>现价</th><th>最近跟踪</th><th>影响度</th><th>性质</th><th>摘要</th><th>操作</th></tr>
|
||||
${rows.map(x => {
|
||||
const p = x.stock, r = x.latest;
|
||||
const meta = r ? (JSON.parse(r.meta || '{}')) : null;
|
||||
const sigCls = meta ? (meta.impact_score >= 65 ? 'tag-强烈推荐' : meta.impact_score >= 45 ? 'tag-推荐' : 'tag-关注') : 'tag-观望';
|
||||
return `<tr>
|
||||
<td><b>${p.name}</b><div style="color:var(--muted);font-size:12px">${p.code} · ${p.industry}</div></td>
|
||||
<td class="num">${p.close}</td>
|
||||
<td style="color:var(--muted)">${r ? r.created_at : '—'}</td>
|
||||
<td>${r && meta ? `<span class="tag ${sigCls}">${meta.impact_score}</span>` : '<span style="color:var(--muted)">未跟踪</span>'}</td>
|
||||
<td>${meta ? `<span class="tag tag-${meta.change_kind === '利好' ? '强烈推荐' : meta.change_kind === '利空' ? '负' : '关注'}">${meta.change_kind}</span>` : '—'}</td>
|
||||
<td style="max-width:300px;white-space:normal;color:var(--text2)">${meta ? escapeHtml(meta.summary || '') : '—'}</td>
|
||||
<td><div class="flex">
|
||||
<button class="btn btn-primary" onclick="runOne('${p.code}')">跟踪</button>
|
||||
${r ? `<button class="btn" onclick="showDetail(${r.id})">详情</button>` : ''}
|
||||
</div></td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</table>`;
|
||||
} catch (e) {
|
||||
$('#posBox').innerHTML = '<div class="empty">加载失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderRecords(items) {
|
||||
if (!items.length) return;
|
||||
$('#recTb').innerHTML = items.map(r => {
|
||||
let meta = {};
|
||||
try { meta = JSON.parse(r.meta || '{}'); } catch (e) {}
|
||||
const sigCls = meta.impact_score >= 65 ? 'tag-强烈推荐' : meta.impact_score >= 45 ? 'tag-推荐' : 'tag-关注';
|
||||
return `<tr class="row-link" onclick="showDetail(${r.id})">
|
||||
<td style="color:var(--muted)">${r.created_at}</td>
|
||||
<td><b>${r.stock_name}</b><div style="color:var(--muted);font-size:12px">${r.code}</div></td>
|
||||
<td style="color:var(--text2)">${escapeHtml(r.industry)}</td>
|
||||
<td><span class="tag ${sigCls}">${meta.impact_score || '—'}</span></td>
|
||||
<td><span class="tag tag-${meta.change_kind === '利好' ? '强烈推荐' : meta.change_kind === '利空' ? '负' : '关注'}">${meta.change_kind || '—'}</span></td>
|
||||
<td style="max-width:320px;white-space:normal;color:var(--text2)">${escapeHtml(meta.summary || '')}</td>
|
||||
<td><button class="btn" onclick="event.stopPropagation();showDetail(${r.id})">查看报告 ↗</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function runAll() {
|
||||
if (!confirm('将对全部自选股执行一次产业链深度跟踪(每只约30-60秒,逐只进行),确定?')) return;
|
||||
try {
|
||||
const r = await api('/api/tracking/run', { method: 'POST' });
|
||||
toast(r.msg || '已启动');
|
||||
pollRefresh();
|
||||
} catch (e) { toast('启动失败:' + e.message); }
|
||||
}
|
||||
|
||||
async function runOne(code) {
|
||||
try {
|
||||
await api('/api/tracking/run/' + code, { method: 'POST' });
|
||||
toast('跟踪已启动,完成后自动刷新');
|
||||
setTimeout(() => { load(); toast('跟踪完成'); }, 80000);
|
||||
} catch (e) { toast('启动失败:' + e.message); }
|
||||
}
|
||||
|
||||
/* 详情弹窗:报告 + 数据源 */
|
||||
async function showDetail(id) {
|
||||
try {
|
||||
const d = await api('/api/tracking/' + id);
|
||||
const meta = d.meta || {}, src = d.sources || {};
|
||||
const sigCls = meta.impact_score >= 65 ? 'tag-强烈推荐' : meta.impact_score >= 45 ? 'tag-推荐' : 'tag-关注';
|
||||
const newsBlock = (label, icon, seg) => {
|
||||
if (!seg) return '';
|
||||
const txt = ((seg.db || '') + (seg.rag || '')).trim();
|
||||
return `<details><summary>${icon} ${label} <span class="src-count">${txt.includes('[') ? txt.split('[').length - 1 : 0}</span></summary>
|
||||
<div class="src-body"><pre class="src-pre">${escapeHtml(txt || '暂无')}</pre></div></details>`;
|
||||
};
|
||||
openModal(`
|
||||
<h3>🧭 产业链跟踪:${d.stock_name}(${d.code})</h3>
|
||||
<div class="news-meta" style="margin-bottom:12px">
|
||||
<span class="cat-tag">${escapeHtml(d.industry)}</span>
|
||||
<span class="tag ${sigCls}">影响度 ${meta.impact_score || '—'}/100</span>
|
||||
<span class="tag tag-${meta.change_kind === '利好' ? '强烈推荐' : meta.change_kind === '利空' ? '负' : '关注'}">${meta.change_kind || '—'}</span>
|
||||
<span style="color:var(--muted)">${d.created_at}</span>
|
||||
</div>
|
||||
<div class="markdown-body">${mdRender(d.report)}</div>
|
||||
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:10px">
|
||||
<b style="color:var(--gold)">数据源(智能体参考内容)</b>
|
||||
${newsBlock('个股直接动态', '📄', src.direct)}
|
||||
${newsBlock('上游产业链(供给/成本)', '⬆️', src.upstream)}
|
||||
${newsBlock('下游产业链(需求/景气)', '⬇️', src.downstream)}
|
||||
${newsBlock('同业竞争', '🏢', src.peers)}
|
||||
<details><summary>📈 技术面 / 机构动向</summary><div class="src-body"><pre class="src-pre">${escapeHtml(src.indicators || '')}</pre></div></details>
|
||||
</div>
|
||||
<div style="margin-top:14px;text-align:right">
|
||||
<button class="btn" onclick="document.querySelector('.modal-mask').classList.remove('show')">关闭</button>
|
||||
<a class="btn btn-primary" href="/stock/${d.code}" target="_blank">查看个股 →</a>
|
||||
</div>
|
||||
`);
|
||||
} catch (e) { toast('加载失败'); }
|
||||
}
|
||||
|
||||
let pollTimer = null;
|
||||
function pollRefresh() {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = setTimeout(() => load(), 30000);
|
||||
}
|
||||
|
||||
load();
|
||||
@@ -30,6 +30,7 @@
|
||||
<a href="/" class="nav-item" data-nav="/">📊 仪表盘</a>
|
||||
<a href="/stocks" class="nav-item" data-nav="/stocks">🏢 股票池</a>
|
||||
<a href="/recommend" class="nav-item" data-nav="/recommend">🎯 荐股中心</a>
|
||||
<a href="/tracking" class="nav-item" data-nav="/tracking">🧭 持仓跟踪</a>
|
||||
<a href="/strategies" class="nav-item" data-nav="/strategies">📈 量化策略</a>
|
||||
<a href="/news" class="nav-item" data-nav="/news">📰 财经新闻</a>
|
||||
<a href="/institutions" class="nav-item" data-nav="/institutions">🏦 机构动向</a>
|
||||
|
||||
@@ -76,6 +76,26 @@
|
||||
<div class="mt16 mini-stats" id="monitorState"></div>
|
||||
</div>
|
||||
|
||||
<!-- 持仓跟踪 -->
|
||||
<div class="card mt16">
|
||||
<div class="between">
|
||||
<div class="card-title" style="margin-bottom:0"><span class="bar" style="background:#8b5cf6"></span>🧭 持仓跟踪智能体</div>
|
||||
<label class="switch"><input type="checkbox" id="trackingEnabled"><span></span></label>
|
||||
</div>
|
||||
<div class="mt8 settings-form">
|
||||
<div class="sf-row"><label>跟踪间隔(分钟)</label><input class="input" id="trackingInterval" type="number" min="15" style="width:120px"></div>
|
||||
<div class="sf-row"><label>重大判定阈值</label><input class="input" id="trackingImpact" type="number" min="0" max="100" style="width:120px"></div>
|
||||
<div class="sf-row"><label>重大变化邮件通知</label>
|
||||
<label class="switch"><input type="checkbox" id="trackingNotify"><span></span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex mt16 wrap">
|
||||
<button class="btn btn-primary" onclick="save('tracking')">💾 保存跟踪设置</button>
|
||||
<button class="btn" onclick="location.href='/tracking'">🧭 打开持仓跟踪页</button>
|
||||
</div>
|
||||
<div class="mt16 mini-stats" id="trackingState"></div>
|
||||
</div>
|
||||
|
||||
<!-- 通知日志 -->
|
||||
<div class="card mt16">
|
||||
<div class="card-title"><span class="bar" style="background:var(--cyan)"></span>通知日志</div>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}持仓跟踪{% endblock %}
|
||||
{% block page_title %}持仓跟踪智能体{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="flex between wrap">
|
||||
<div style="color:var(--text2);font-size:13px;line-height:1.9">
|
||||
🤖 <b style="color:var(--text)">大模型智能体 + 工作流</b>:定期跟踪<b>自选股(持仓)</b>,除个股公告新闻外,
|
||||
深度分析其<b>产业链上下游与同业</b>动态(上游供给/成本、下游需求/景气、竞争格局),
|
||||
输出专业跟踪报告;发现<b>重大变化自动邮件通知</b>。
|
||||
<div style="color:var(--muted);font-size:12px">跟踪对象 = 自选股(⭐ 股票池/详情页添加)· 自动跟踪间隔可在「系统设置」配置</div>
|
||||
</div>
|
||||
<div class="flex wrap">
|
||||
<button class="btn btn-primary" onclick="runAll()">⚡ 立即跟踪全部持仓</button>
|
||||
<button class="btn" onclick="load()">🔄 刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt16 kpi-grid" id="trackState"></div>
|
||||
</div>
|
||||
|
||||
<div class="card mt16">
|
||||
<div class="card-title"><span class="bar"></span>持仓(自选股)最近跟踪</div>
|
||||
<div id="posBox"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card mt16">
|
||||
<div class="card-title"><span class="bar" style="background:var(--gold)"></span>全部跟踪记录</div>
|
||||
<div style="overflow:auto;max-height:520px">
|
||||
<table>
|
||||
<thead><tr><th>时间</th><th>股票</th><th>行业</th><th>影响度</th><th>性质</th><th>摘要</th><th>操作</th></tr></thead>
|
||||
<tbody id="recTb"><tr><td colspan="7" class="empty">暂无跟踪记录,点击上方「立即跟踪全部持仓」开始</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/tracking.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user