2 Commits
12 changed files with 1298 additions and 4 deletions
+342
View File
@@ -0,0 +1,342 @@
# 📈 智能荐股系统(Stock Advisor
一个专业的股票推荐与量化分析系统:**多因子荐股评分 + RAG 增强 AI 研报 + 主流量化策略回测 + 舆情驱动自动化通知**。
- **技术栈**Python Flask + SQLite + Chroma 向量库 + bge-large-zh 语义检索 + DeepSeek 大模型
- **默认端口**16095
- **Git**`hz4th_coder/stock-advisor`v1.3.0
- **数据模式**:当前为模拟数据(`IS_MOCK=True`),后期可无缝切换真实数据
---
## ✨ 功能总览
| 模块 | 页面 | 说明 |
|---|---|---|
| 📊 仪表盘 | `/` | 三大指数、市场情绪、今日荐股 TOP5、行业热度、最新要闻、自选股 |
| 🏢 股票池 | `/stocks` | 68 只股票搜索/行业/板块/评级筛选,一键 ⭐ 自选 |
| 🎯 荐股中心 | `/recommend` | 六因子评分排名 + 推荐理由 + AI 深度分析入口 |
| 📈 量化策略 | `/strategies` | 6 主流策略全市场回测榜 + 单股净值曲线 + 交易明细 |
| 📰 财经新闻 | `/news` | 新闻分类/搜索/情感标签/关联个股,RAG 语料库 |
| 🏦 机构动向 | `/institutions` | 27 家机构、评级变动榜、基金增减持榜、机构详情 |
| 📄 个股详情 | `/stock/<code>` | ECharts K线、技术指标、六因子评分、相关资讯、AI 研报、历史分析 |
| 📑 AI 分析详情 | `/analysis/<id>` | 研报正文 + **大模型参考的数据源**(RAG新闻/概况/指标/评级/持仓/提示词) |
| 🔧 系统设置 | `/settings` | 邮件 SMTP、大模型接口、舆情监控参数配置 |
| ⚙️ 数据管理 | `/admin` | 数据统计、向量库状态、一键重灌、重建回测、依赖体检 |
---
## 🏗️ 技术架构
```
┌─────────────────────────────────────────────────────────┐
│ 前端(模板 + 原生JS + ECharts
│ 仪表盘 / 股票池 / 荐股 / 策略 / 新闻 / 机构 / 设置 / 详情 │
└──────────────────────────┬──────────────────────────────┘
│ HTTP (Flask)
┌──────────────────────────▼──────────────────────────────┐
│ app.py(路由/API 层) │
│ ┌──────────┬──────────┬──────────┬───────────────────┐ │
│ │ 评分荐股 │ AI研报 │ 量化回测 │ 舆情监控+设置 │ │
│ │scoring.py│analyst.py│strategies│notifier+settings │ │
│ └──────────┴──────────┴──────────┴───────────────────┘ │
│ indicators.py(技术指标) vector_store.py(向量REST
└───────────────┬──────────────────────────┬──────────────┘
│ │
┌──────────▼──────────┐ ┌──────────▼───────────────┐
│ SQLite (本地库) │ │ Chroma(16010) + bge(16011)│
│ stock_advisor.db │ │ 新闻索引/公司概况索引 │
└─────────────────────┘ └──────────────────────────┘
┌──────────▼──────────┐
│ DeepSeek (RAG研报) │ SMTP (舆情邮件通知)
└─────────────────────┘
```
- **向量库**:纯 REST 直连(无 chromadb 客户端依赖),Embedding 用 bge-large-zh-v1.51024 维)
- **大模型**DeepSeek `deepseek-v4-flash`(推理型,研报生成约 25-40 秒,后台线程 + 前端轮询)
- **定时任务**:舆情监控调度器(后台守护线程,默认 30 分钟一轮)
---
## 📂 目录结构
```
stock-advisor/
├── app.py # Flask 主应用(页面 + 全部 API)
├── config.py # 全局配置(端口/LLM/向量库/邮件/监控默认值)
├── database.py # SQLite 连接与表结构(13 张表)
├── settings.py # 设置管理器(settings 表 + 默认值合并)
├── seed_data.py # 模拟数据生成器(行情/新闻/机构/持仓 + 向量/回测构建)
├── start.sh # 启动/停止/重启/状态/重灌脚本
├── requirements.txt # 依赖:flask, requests
├── engine/
│ ├── indicators.py # 技术指标:MA/RSI/MACD/KDJ/量比/波动率
│ ├── scoring.py # 六因子评分模型 + 规则化推荐理由
│ ├── analyst.py # DeepSeek 研报 + RAG 增强 + 历史记录
│ ├── strategies.py # 量化策略信号生成 + 回测引擎
│ └── notifier.py # 舆情监控:重要度评分 + SMTP 邮件 + 调度器
├── rag/
│ └── vector_store.py # Chroma/Embedding 纯 REST 封装
├── templates/ # 10 个页面模板
├── static/
│ ├── css/style.css
│ ├── js/ # 各页面脚本
│ └── lib/marked.min.js # Markdown 渲染(本地无 CDN
├── data/ # stock_advisor.db(运行时生成)
└── logs/ # 运行日志
```
---
## 🚀 快速开始
```bash
cd works/stock-advisor
./start.sh start # 启动(端口 16095
./start.sh status # 查看状态
./start.sh stop # 停止
./start.sh restart # 重启
./start.sh seed # 一键重灌数据(含向量重建 + 策略回测,约 1-3 分钟)
# 手动执行数据生成(可选参数)
/home/hz1/miniconda3/envs/openclaw/bin/python3 seed_data.py # 全量(DB+向量+回测)
/home/hz1/miniconda3/envs/openclaw/bin/python3 seed_data.py --skip-vector # 跳过向量重建
/home/hz1/miniconda3/envs/openclaw/bin/python3 seed_data.py --no-strategies # 跳过策略回测
```
**访问**`http://<服务器IP>:16095/`
**依赖环境**openclaw conda 环境(`flask``requests`);外部服务 Chroma 16010 + Embedding 16011(本机常驻)。
---
## 🗄️ 数据库设计(SQLite,13 张表)
| 表 | 说明 | 关键字段 |
|---|---|---|
| `stocks` | 股票基础信息(68 只) | code/name/industry/board/market_cap/pe/pb/description |
| `stock_daily` | 日线行情(12240 条) | code/date/OHLC/volume/amount/change_pct |
| `market_index` | 上证/深证/创业板指数 | date/sh/sz/cy |
| `news` | 财经新闻(305 条,RAG 语料) | title/content/category/sentiment/related_stocks |
| `institutions` | 机构实体(27 家) | name/type(公募/券商/保险/外资/私募)/description |
| `inst_ratings` | 机构评级(165 条) | inst_id/stock_code/rating/target_price/rating_date |
| `fund_holdings` | 基金季度持仓(363 条) | inst_id/stock_code/quarter/hold_value/change_pct |
| `watchlist` | 用户自选股 | code |
| `analysis_cache` | 研报最新缓存 | code/report |
| `analysis_history` | AI 分析历史(含数据源 JSON | code/focus/report/sources |
| `strategy_backtests` | 策略回测结果(408 条) | strategy/code/metrics/equity/trades |
| `settings` | 系统设置 KV | key/value |
| `notification_log` | 舆情通知日志 | news_id/importance/status |
---
## 🎯 核心模块详解
### 1. 六因子评分模型(engine/scoring.py
满分 100,评级:**强烈推荐 ≥82 / 推荐 ≥68 / 关注 ≥55 / 观望 <55**
| 因子 | 满分 | 依据 |
|---|---|---|
| 趋势 | 25 | 均线多头排列 + 站上 MA20 |
| 动量 | 20 | 5 日涨幅区间映射(过急扣分防追高) |
| 技术 | 15 | RSI 健康区间 / 超买超卖 |
| 量能 | 10 | 量比 |
| 消息 | 15 | 近 7 日相关新闻情感均值 |
| 机构 | 15 | 近 30 日正面评级数 + 基金持仓动向 |
同时生成**规则化推荐理由**(趋势/动量/RSI/量能/消息/机构),列表页直接展示,无需 LLM。
### 2. AI 深度研报(engine/analyst.pyRAG 增强)
```
触发 → RAG检索(个股新闻向量命中 + 公司概况) + 技术指标 + 机构评级/持仓 + 六因子评分
→ 组装提示词 → DeepSeek 推理生成 Markdown 研报
→ 写入 analysis_history(含数据源快照)→ 前端轮询展示
```
研报结构:公司概况 / 技术面 / 消息面 / 机构动向 / 风险提示 / 操作建议(目标区间·支撑·压力位)。
**数据源详情页 `/analysis/<id>`** 可追溯每次分析参考的:
- 📰 RAG 相关资讯(含情感 + 向量相似度)
- 🏢 公司概况 / 📈 技术指标 / 🎯 综合评分
- 🏦 机构评级 / 💼 基金持仓 / 🧠 完整提示词
### 3. 量化策略回测(engine/strategies.py
**6 个主流策略**(全市场 × 6 = 408 条预计算,秒级加载):
| 策略 | 参数 | 类型 |
|---|---|---|
| 双均线金叉 | MA5/MA20 | 趋势 |
| MACD 金叉 | 12/26/9 | 趋势 |
| RSI 超买超卖 | RSI(14) 30/70 | 反转 |
| 布林带回归 | 20日/2σ | 均值回归 |
| 20日动量 | 20日涨幅/MA20 | 动量 |
| N日新高突破 | 20日高低点 | 突破 |
**回测规则**:收盘产生信号 → **次日开盘成交**(全仓多头,避免未来函数);指标含总收益/年化/最大回撤/夏普/胜率/盈亏比/交易次数/超额收益;并对比**买入持有**基准净值曲线。
> 扩展新策略:在 `STRATEGIES` 注册 name/desc + 实现 `_signals()` 即可。
### 4. 舆情驱动自动化(engine/notifier.py
**流程**:后台调度线程(默认 30 分钟)→ 扫描新新闻(水位去重)→ **重要度评分** → 达标 → **SMTP 邮件通知**
**重要度评分(0-100**
```
类别基础分(公司40/业绩35/机构30/行业25/市场15
+ |情感| × 情感权重 × 100
+ 关键词命中 ×12/个(回购/中标/减持/问询/超预期…)
+ 关联个股 ×5/只
```
**防打扰机制**`monitor_last_news_id` 水位只扫新增;首次启动只建水位不通知历史;单封邮件最多 20 条。
### 5. 系统设置(/settings
- **📧 邮件**:SMTP 服务器/端口/加密模式(plain/starttls/ssl/账号/密码/收件人 + 测试邮件
- **🤖 大模型**base_url / api_key / model**运行时生效**+ 测试连接
- **📰 监控**:开关/间隔/分类/阈值/关键词 + 立即扫描 + 通知日志
> 设置存入 `settings` 表,优先于 `config.py` 默认值,无需改代码。
---
## 🔌 API 文档
### 页面
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/` `/stocks` `/recommend` `/strategies` `/news` `/institutions` `/settings` `/admin` | 各页面 |
| GET | `/stock/<code>` | 个股详情页 |
| GET | `/analysis/<id>` | AI 分析详情页 |
### 行情/股票
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/health` | 健康检查 |
| GET | `/api/overview` | 仪表盘聚合数据(指数/情绪/荐股/要闻/自选) |
| GET | `/api/stocks?keyword=&industry=&board=&rating=&sort=&page=` | 股票列表(含评分) |
| GET | `/api/stock/<code>` | 个股详情 + 指标 + 评分 |
| GET | `/api/stock/<code>/kline?days=` | K线数据 + MA 序列 |
| GET | `/api/stock/<code>/news` | 个股相关新闻 |
| GET | `/api/stock/<code>/institutions` | 个股机构评级 + 基金持仓 |
### 荐股/AI
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/recommend?rating=&limit=` | 评分排名 |
| POST | `/api/stock/<code>/analyze` | 提交 AI 研报任务 `{focus}` |
| GET | `/api/stock/<code>/analyze/status` | 轮询研报状态 |
| GET | `/api/stock/<code>/analyses` | 历史分析列表 |
| GET | `/api/analyses/<id>` | 分析详情(含数据源) |
### 自选
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/watchlist` | 自选列表 |
| POST | `/api/watchlist/<code>` | 加入自选 |
| DELETE | `/api/watchlist/<code>` | 移出自选 |
### 新闻/机构
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/news?keyword=&category=&page=` | 新闻列表(分页) |
| GET | `/api/news/<id>` | 新闻详情(含关联股票) |
| GET | `/api/institutions?type=` | 机构列表 |
| GET | `/api/institutions/<id>` | 机构详情 |
| GET | `/api/ratings/upgrades` | 评级变动榜 |
| GET | `/api/holdings/moves` | 基金增减持榜 |
### 量化策略
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/strategies` | 策略列表 + 全市场统计 |
| GET | `/api/backtest/market?strategy=` | 某策略全市场收益榜 |
| GET | `/api/backtest?strategy=&code=` | 单股回测详情(净值+交易) |
| POST | `/api/backtest/rebuild` | 重建全市场回测 |
### 设置/监控
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/settings` | 读取全部设置 |
| POST | `/api/settings` | 保存设置 `{mail, llm, monitor}` |
| POST | `/api/settings/test-email` | 发送测试邮件 |
| POST | `/api/settings/test-llm` | 测试大模型连接 |
| POST | `/api/monitor/scan` | 立即扫描新闻 |
| GET | `/api/monitor/log` | 通知日志 |
### 数据管理
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/admin/stats` | 表统计 + 向量库状态 |
| POST | `/api/admin/reseed` | 一键重灌数据 |
| GET | `/api/admin/healthcheck` | 外部依赖连通性检查 |
---
## 🔌 外部依赖
| 服务 | 地址 | 用途 |
|---|---|---|
| Chroma 向量库 | `121.40.164.32:16010` | 新闻/概况向量索引 |
| Embedding (bge-large-zh-v1.5) | `121.40.164.32:16011` | 1024 维语义向量 |
| DeepSeek API | `https://api.deepseek.com` | AI 研报生成 |
| SMTP | `mail.tphai.com:587` | 舆情邮件通知 |
> 向量集合:`stock_news_v1`(新闻 474 条)/ `stock_profiles_v1`(公司概况 68 条)
---
## 📦 模拟数据说明
当前所有行情/新闻/机构数据均为**模拟数据**(页面有"模拟数据"标识),用于功能演示与系统验证:
- 68 只 A 股风格股票,覆盖 20+ 行业(白酒/新能源/半导体/医药/银行/券商…)
- 180 个交易日日线(随机游走 + 趋势分化,让荐股/回测有区分度)
- 305 条财经新闻(业绩/行业/公司/机构观点/市场 5 类,带情感标签 + 关联个股)
- 27 家机构(公募/券商/保险资管/外资/私募)+ 165 条评级 + 363 条季度持仓
### 接入真实数据
```python
# 1. 改 config.py
IS_MOCK = False
# 2. 替换 seed_data.py 的数据源(真实行情/新闻/机构接口),保持表结构不变
# - stocks / stock_daily / market_index → 真实行情
# - news → 真实新闻(保留 category / sentiment / related_stocks 字段)
# - institutions / inst_ratings / fund_holdings → 真实机构数据
# 3. 重灌
./start.sh seed
```
分析/检索/回测/监控逻辑**零改动**即可对接真实数据。
---
## 📝 版本历史
| 版本 | 内容 |
|---|---|
| v1.0.0 | 基础版:股票池/行情/新闻/机构/多因子评分/AI 研报(RAG) |
| v1.0.1 | 修复:重灌后机构 ID 自增未重置、行业板块字段错位、研报评分口径 |
| v1.1.0 | AI 分析历史记录 + 数据源详情页(/analysis/&lt;id&gt;,展示 RAG 新闻/提示词等) |
| v1.2.0 | 量化策略模块:6 主流策略全市场回测 + 单股净值曲线 + 交易明细 |
| v1.3.0 | 舆情驱动自动化(定期扫新闻→邮件通知)+ 系统设置区(邮件/大模型/监控可配) |
---
## 🗺️ 后续规划(可叠加的高级能力)
- 模拟盘/实盘信号推送(每日盘前舆情日报)
- 策略参数调优、策略组合与仓位管理
- 自选股策略联动、财务因子库(真实财报数据)
- 多模型对比研报、行业轮动分析
---
> ⚠️ 本系统所有内容基于模拟数据生成,仅供系统演示与量化研究,**不构成任何投资建议**。
+72 -2
View File
@@ -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)
+8
View File
@@ -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
View File
@@ -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 '{}', -- JSONsignificance/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
View File
@@ -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
+219
View File
@@ -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
View File
@@ -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)
+20
View File
@@ -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); }
+143
View File
@@ -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();
+1
View File
@@ -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>
+20
View File
@@ -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>
+38
View File
@@ -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 %}