Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4da5e091a5 | ||
|
|
adc54d509f | ||
|
|
7d8ed3d7de | ||
|
|
9406f9de43 |
@@ -16,7 +16,7 @@
|
||||
| 📊 仪表盘 | `/` | 三大指数、市场情绪、今日荐股 TOP5、行业热度、最新要闻、自选股 |
|
||||
| 🏢 股票池 | `/stocks` | 68 只股票搜索/行业/板块/评级筛选,一键 ⭐ 自选 |
|
||||
| 🎯 荐股中心 | `/recommend` | 六因子评分排名 + 推荐理由 + AI 深度分析入口 |
|
||||
| ⏰ 自动化 | `/automation` | 双选项卡:舆情驱动自动化 + 持仓跟踪智能体(配置/状态/日志/报告) |
|
||||
| ⏰ 自动化 | `/automation` | 双选项卡:舆情驱动自动化 + 持仓跟踪智能体(配置/持仓管理/目标管理/日志/报告) |
|
||||
| 📈 量化策略 | `/strategies` | 6 主流策略全市场回测榜 + 单股净值曲线 + 交易明细 |
|
||||
| 📰 财经新闻 | `/news` | 新闻分类/搜索/情感标签/关联个股,RAG 语料库 |
|
||||
| 🏦 机构动向 | `/institutions` | 27 家机构、评级变动榜、基金增减持榜、机构详情 |
|
||||
@@ -24,7 +24,7 @@
|
||||
| 📑 AI 分析详情 | `/analysis/<id>` | 研报正文 + **大模型参考的数据源**(RAG新闻/概况/指标/评级/持仓/提示词) |
|
||||
| 🧭 持仓跟踪报告 | `/tracking` | 产业链深度跟踪报告总览(⏰ 自动化页入口进入) |
|
||||
| 🔧 系统设置 | `/settings` | 邮件 SMTP + 大模型接口配置 |
|
||||
| ⚙️ 数据管理 | `/admin` | 数据统计、向量库状态、一键重灌、重建回测、依赖体检 |
|
||||
| ⚙️ 数据管理 | `/admin` | 数据统计、向量库状态、一键重灌、重建回测、**定时报告手动触发与日志** |
|
||||
|
||||
---
|
||||
|
||||
@@ -130,7 +130,10 @@ 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 |
|
||||
| `watch_targets` | 跟踪目标(概念/主题/股票) | type/name/keywords/enabled |
|
||||
| `tracking_reports` | 跟踪报告(含类型/数据源/影响度 JSON) | target_type/code/report/meta |
|
||||
| `report_log` | 定时报告发送日志 | kind/subject/status |
|
||||
| `global_markets` | 全球主要指数(模拟) | date/data(JSON) |
|
||||
|
||||
---
|
||||
|
||||
@@ -199,28 +202,50 @@ cd works/stock-advisor
|
||||
|
||||
### 5. 持仓跟踪智能体(engine/agent.py + chain_data.py)
|
||||
|
||||
对**自选股(持仓)**定期主动跟踪,由**大模型智能体 + 工作流**驱动:
|
||||
对**持仓(自选股)**与**目标概念/主题/股票**定期主动跟踪,由**大模型智能体 + 工作流**驱动:
|
||||
|
||||
```
|
||||
工作流:读取产业链知识库 → RAG+DB 采集(个股/上游/下游/同业) → LLM 产业链深度分析
|
||||
工作流:读取产业链知识库 → RAG+DB 采集(个股/上游/下游/同业 或 概念资讯/受益股) → LLM 深度分析
|
||||
→ 影响度判定(0-100) → 入库 tracking_reports → 重大变化(≥阈值)邮件通知
|
||||
```
|
||||
|
||||
- **跟踪对象**(`watch_targets` 表 + 自选股)
|
||||
- 💼 持仓(自选股):⏰ 自动化→持仓管理 或 股票池/详情页 ⭐ 添加
|
||||
- 🎯 概念/主题目标:如「AI算力」「低空经济」「固态电池」(关键词检索)
|
||||
- 🎯 股票目标:额外跟踪的个股代码
|
||||
- **产业链知识库** `chain_data.py`:覆盖 40+ 行业,每个行业定义 上游(供给/成本)/下游(需求/客户)/同业 + 检索关键词(引用池内股票代码便于 RAG 过滤)
|
||||
- **深度分析报告**结构:个股最新动态 / 上游产业链分析 / 下游产业链分析 / 同行业竞争格局 / 产业链传导与投资启示 / 风险提示 / 关注要点
|
||||
- **个股深度分析**:个股最新动态 / 上游产业链分析 / 下游产业链分析 / 同行业竞争格局 / 产业链传导与投资启示 / 风险提示 / 关注要点
|
||||
- **概念/主题深度分析**:题材最新动态 / 核心驱动与催化 / 受益标的梳理 / 市场情绪与资金 / 风险提示 / 关注要点
|
||||
- **影响度判定**:大模型输出 JSON(significance / impact_score / change_kind / summary / chain_trend),前置输出防截断,容错解析
|
||||
- **主动调度**:后台调度器按间隔(默认 60 分钟)跟踪全部自选股;支持「立即跟踪全部/单只」
|
||||
- **重大变化通知**:impact_score ≥ 阈值(默认 65)自动邮件
|
||||
- **页面 `/tracking`**:持仓最近跟踪一览 + 全部跟踪记录 + 详情弹窗(报告 + 四环节数据源)
|
||||
- **主动调度**:后台调度器按间隔(默认 60 分钟)跟踪全部持仓+目标;支持「立即跟踪全部目标/单只」
|
||||
- **重大变化通知**:impact_score ≥ 阈值(默认 65)自动邮件(股票/概念通用)
|
||||
- **页面 `/tracking`**:股票+概念报告总览;**⏰ 自动化**页可管理持仓与目标、查看最近报告
|
||||
|
||||
### 5. 系统设置(/settings)
|
||||
### 6. 系统设置(/settings)
|
||||
|
||||
- **📧 邮件**:SMTP 服务器/端口/加密模式(plain/starttls/ssl)/账号/密码/收件人 + 测试邮件
|
||||
- **🤖 大模型**:base_url / api_key / model(**运行时生效**)+ 测试连接
|
||||
- **📰 监控**:开关/间隔/分类/阈值/关键词 + 立即扫描 + 通知日志
|
||||
|
||||
> 设置存入 `settings` 表,优先于 `config.py` 默认值,无需改代码。
|
||||
|
||||
### 7. 每日定时报告(engine/report.py + reports.py + cron)
|
||||
|
||||
工作日自动发送两封行情报告到邮箱:
|
||||
|
||||
| 时段 | cron | 内容 |
|
||||
|---|---|---|
|
||||
| 🌅 盘前分析 | `0 9 * * 1-5` | 昨日市场回顾 / 昨日至今要闻 / 全球市场 / 持仓与关注目标 / 盘前研判 |
|
||||
| 🌇 盘后总结 | `30 15 * * 1-5` | 今日市场总结 / 今日要闻 / 全球市场 / 持仓表现 / 盘后研判 |
|
||||
|
||||
每期输出**两份报告**:
|
||||
- **简单版**:邮件正文(指数/涨跌结构/要闻/持仓/目标 + AI 研判速览)
|
||||
- **详细版**:HTML 附件(数据总览表格 + 全球市场 + 持仓明细 + AI 深度分析全文)
|
||||
|
||||
- **全球市场数据** `global_markets` 表(模拟):道琼斯/纳斯达克/标普/恒生/日经/KOSPI/德法英等 9 大指数
|
||||
- 报告由 DeepSeek 基于当日数据生成研判(简版+详版两次调用),失败自动降级为数据速览
|
||||
- **手动触发**:数据管理页「定时报告」卡片(🌅/🌇 按钮)+ 发送日志 `report_log` 表
|
||||
- CLI:`python3 reports.py premarket|postmarket`
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API 文档
|
||||
@@ -357,6 +382,8 @@ IS_MOCK = False
|
||||
| v1.3.0 | 舆情驱动自动化(定期扫新闻→邮件通知)+ 系统设置区(邮件/大模型/监控可配) |
|
||||
| v1.4.0 | 持仓跟踪智能体:产业链上下游深度分析(上游供给/成本、下游需求/景气、同业),影响度判定+重大变化通知 |
|
||||
| v1.5.0 | 舆情监控与持仓跟踪合并为「⏰ 自动化」导航入口(双选项卡);修复布尔设置存储兼容 |
|
||||
| v1.6.0 | 目标跟踪:概念/主题/股票(watch_targets),概念智能体分析+受益个股,持仓管理与目标管理,报告支持双类型 |
|
||||
| v1.7.0 | 每日定时报告:工作日 9:00 盘前 / 15:30 盘后,简版正文+详细版附件,全球市场数据,cron 定时+手动触发 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -689,18 +689,18 @@ def api_tracking_detail(rid):
|
||||
|
||||
@app.route("/api/tracking/run", methods=["POST"])
|
||||
def api_tracking_run():
|
||||
"""立即跟踪全部自选股(后台)"""
|
||||
"""立即跟踪全部目标(持仓+概念/主题/股票,后台)"""
|
||||
import threading
|
||||
|
||||
def run():
|
||||
from engine.agent import track_watchlist
|
||||
from engine.agent import track_all
|
||||
try:
|
||||
track_watchlist()
|
||||
track_all()
|
||||
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秒)"})
|
||||
return jsonify({"ok": True, "msg": "跟踪任务已启动(逐目标分析,约每只30-60秒)"})
|
||||
|
||||
|
||||
@app.route("/api/tracking/run/<code>", methods=["POST"])
|
||||
@@ -718,6 +718,55 @@ def api_tracking_run_one(code):
|
||||
return jsonify({"ok": True, "msg": "跟踪已启动"})
|
||||
|
||||
|
||||
@app.route("/api/targets")
|
||||
def api_targets_list():
|
||||
from engine.agent import list_targets
|
||||
return jsonify({"items": list_targets()})
|
||||
|
||||
|
||||
@app.route("/api/targets", methods=["POST"])
|
||||
def api_targets_add():
|
||||
from engine.agent import add_target
|
||||
body = request.get_json(silent=True) or {}
|
||||
r = add_target(body.get("type", "concept"), body.get("name", ""),
|
||||
body.get("code", ""), body.get("keywords", ""))
|
||||
if r.get("error"):
|
||||
return jsonify(r), 400
|
||||
return jsonify(r)
|
||||
|
||||
|
||||
@app.route("/api/targets/<int:tid>", methods=["DELETE"])
|
||||
def api_targets_delete(tid):
|
||||
from engine.agent import delete_target
|
||||
return jsonify(delete_target(tid))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 定时报告
|
||||
@app.route("/api/report/send", methods=["POST"])
|
||||
def api_report_send():
|
||||
"""手动触发定时报告(后台生成 + 邮件)"""
|
||||
from engine.report import send_daily_report
|
||||
import threading
|
||||
body = request.get_json(silent=True) or {}
|
||||
kind = body.get("kind", "premarket")
|
||||
|
||||
def run():
|
||||
try:
|
||||
r = send_daily_report(kind)
|
||||
log.info("report send: %s", r)
|
||||
except Exception as e:
|
||||
log.error("report send fail: %s", e)
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
return jsonify({"ok": True, "msg": f"{'盘前分析' if kind == 'premarket' else '盘后总结'}生成已启动,约需 1-2 分钟"})
|
||||
|
||||
|
||||
@app.route("/api/report/log")
|
||||
def api_report_log():
|
||||
from engine.report import report_log
|
||||
return jsonify({"items": report_log()})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 数据管理
|
||||
@app.route("/api/admin/stats")
|
||||
def api_admin_stats():
|
||||
|
||||
+34
-2
@@ -142,10 +142,11 @@ CREATE TABLE IF NOT EXISTS notification_log (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tracking_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
target_type TEXT DEFAULT 'stock', -- stock(股票) / concept(概念主题)
|
||||
code TEXT NOT NULL,
|
||||
stock_name TEXT DEFAULT '',
|
||||
industry TEXT DEFAULT '',
|
||||
report TEXT DEFAULT '', -- 产业链深度分析(markdown)
|
||||
report TEXT DEFAULT '', -- 产业链/概念深度分析(markdown)
|
||||
meta TEXT DEFAULT '{}', -- JSON:significance/impact_score/summary/新闻统计
|
||||
sources TEXT DEFAULT '{}', -- JSON:个股/上游/下游/同业 采集的资讯 + 指标 + 评级
|
||||
status TEXT DEFAULT 'done',
|
||||
@@ -153,12 +154,38 @@ CREATE TABLE IF NOT EXISTS tracking_reports (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracking_code ON tracking_reports(code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS watch_targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT DEFAULT 'concept', -- concept(概念/主题) / stock(股票)
|
||||
code TEXT DEFAULT '', -- stock 时的股票代码
|
||||
name TEXT NOT NULL, -- 概念/主题名 或 股票名
|
||||
keywords TEXT DEFAULT '', -- concept 的检索关键词(逗号分隔)
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS report_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT DEFAULT '', -- premarket / postmarket
|
||||
subject TEXT DEFAULT '',
|
||||
brief_len INTEGER DEFAULT 0,
|
||||
detail_len INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'sent',
|
||||
message TEXT DEFAULT '',
|
||||
sent_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS market_index (
|
||||
date TEXT PRIMARY KEY,
|
||||
sh REAL DEFAULT 0, -- 上证指数(点)
|
||||
sz REAL DEFAULT 0, -- 深证成指(点)
|
||||
cy REAL DEFAULT 0 -- 创业板指(点)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS global_markets (
|
||||
date TEXT PRIMARY KEY,
|
||||
data TEXT DEFAULT '{}' -- JSON:全球主要指数 {key: {value, chg}}
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
@@ -187,6 +214,10 @@ def db():
|
||||
def init_db():
|
||||
with db() as conn:
|
||||
conn.executescript(SCHEMA)
|
||||
# 迁移:老库补列
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(tracking_reports)")}
|
||||
if "target_type" not in cols:
|
||||
conn.execute("ALTER TABLE tracking_reports ADD COLUMN target_type TEXT DEFAULT 'stock'")
|
||||
|
||||
|
||||
def query(sql, args=()):
|
||||
@@ -219,7 +250,8 @@ 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", "tracking_reports"):
|
||||
"market_index", "strategy_backtests", "notification_log", "tracking_reports",
|
||||
"watch_targets", "global_markets", "report_log"):
|
||||
with db() as conn:
|
||||
conn.execute(f'DELETE FROM "{t}"')
|
||||
with db() as conn:
|
||||
|
||||
+166
-25
@@ -261,8 +261,8 @@ def track_stock(code, focus=""):
|
||||
"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'))",
|
||||
"INSERT INTO tracking_reports(target_type, code, stock_name, industry, report, meta, sources, status, created_at) "
|
||||
"VALUES('stock',?,?,?,?,?,?,'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"]
|
||||
@@ -287,60 +287,201 @@ def _first_heading(text):
|
||||
|
||||
|
||||
def _notify_if_significant(rid, stock, meta):
|
||||
"""影响度达阈值且开启通知 → 邮件"""
|
||||
"""影响度达阈值且开启通知 → 邮件(股票/概念通用)"""
|
||||
cfg = tracking_config()
|
||||
try:
|
||||
if cfg["notify"] and int(meta["impact_score"]) >= int(cfg["impact_threshold"]):
|
||||
if cfg["notify"] and int(meta.get("impact_score", 0)) >= int(cfg["impact_threshold"]):
|
||||
from engine.notifier import send_email
|
||||
mc = mail_config()
|
||||
nc = meta.get("news_counts") or {}
|
||||
detail = (f"个股资讯 {nc.get('direct', 0)} 条 / 上游 {nc.get('upstream', 0)} 条 / "
|
||||
f"下游 {nc.get('downstream', 0)} 条 / 同业 {nc.get('peers', 0)} 条"
|
||||
if "upstream" in nc else
|
||||
f"相关资讯 {nc.get('direct', 0)} 条 / 语义检索 {nc.get('rag', 0)} 条 / "
|
||||
f"受益个股 {nc.get('related', 0)} 只")
|
||||
send_email(
|
||||
f"[持仓跟踪] {stock['name']} 出现{meta.get('change_kind','')}动态(影响度{meta['impact_score']})",
|
||||
f"[持仓跟踪] {stock['name']} 出现{meta.get('change_kind', '')}动态(影响度{meta.get('impact_score', 0)})",
|
||||
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="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>
|
||||
<p><b>影响度:</b>{meta.get('impact_score', 0)}/100({'🔴 重大' if int(meta.get('impact_score', 0)) >= 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;">{detail}</p>
|
||||
</div></div></body></html>""",
|
||||
cfg=mc)
|
||||
set_tracking_state(last_alert=int(meta["impact_score"]))
|
||||
set_tracking_state(last_alert=int(meta.get("impact_score", 0)))
|
||||
except Exception as e:
|
||||
log.warning("track notify fail: %s", e)
|
||||
|
||||
|
||||
# ===================================================================== 概念/主题跟踪
|
||||
def track_concept(name, keywords="", focus=""):
|
||||
"""跟踪一个概念/主题:采集相关新闻 → 受益个股梳理 → LLM 深度分析 → 影响度判定"""
|
||||
kw = keywords.strip() or name
|
||||
kw_list = [k.strip() for k in kw.replace(",", ",").split(",") if k.strip()]
|
||||
kws = " ".join(kw_list)
|
||||
# 1) 采集:DB 关键词检索 + RAG 向量检索
|
||||
conds, args = [], []
|
||||
for k in kw_list[:5]:
|
||||
conds.append("(title LIKE ? OR content LIKE ?)")
|
||||
args += [f"%{k}%", f"%{k}%"]
|
||||
args.append(15)
|
||||
db_news = query(
|
||||
f"SELECT id,title,content,source,category,publish_date,sentiment,related_stocks FROM news "
|
||||
f"WHERE ({' OR '.join(conds)}) AND publish_date >= date('now','-45 day') "
|
||||
f"ORDER BY publish_date DESC LIMIT ?", args)
|
||||
rag_hits = _rag_news_text(f"{name} 概念主题 {kws} 最新动态 政策 催化", top_k=8)
|
||||
db_text = _fmt_news(db_news)
|
||||
rag_text = "\n".join(rag_hits) or " (暂无)"
|
||||
# 2) 受益个股:从 DB 新闻 related_stocks + RAG 命中元数据 code 汇总
|
||||
codes = set()
|
||||
for n in db_news:
|
||||
for c in (n["related_stocks"] or "").split(","):
|
||||
if c:
|
||||
codes.add(c)
|
||||
try:
|
||||
for h in query_vectors(f"{name} {kws} 受益个股", n_results=8, name=CHROMA_NEWS_COLLECTION):
|
||||
c = (h.get("metadata") or {}).get("code")
|
||||
if c:
|
||||
codes.add(c)
|
||||
except Exception:
|
||||
pass
|
||||
related = query("SELECT code, name, industry FROM stocks WHERE code IN (%s)" %
|
||||
",".join(["?"] * len(codes)), tuple(codes)) if codes else []
|
||||
related_txt = "、".join(f"{r['name']}({r['code']},{r['industry']})" for r in related) or "暂无"
|
||||
# 3) 提示词
|
||||
prompt = f"""你是资深题材/概念跟踪分析师,正在深度跟踪【{name}】这一概念主题。
|
||||
|
||||
【概念/主题】{name}
|
||||
【检索关键词】{kws}
|
||||
|
||||
【最新相关资讯】
|
||||
{db_text}
|
||||
{rag_text}
|
||||
|
||||
【关联受益个股】{related_txt}
|
||||
|
||||
【输出要求】
|
||||
第一步,先输出一个 json 代码块(必须最先输出):
|
||||
```json
|
||||
{{"significance":"high|medium|low","impact_score":0到100的整数,"change_kind":"利好/利空/中性/震荡","summary":"一句话总结","chain_trend":"题材趋势判断"}}
|
||||
```
|
||||
第二步,输出 Markdown 分析报告,结构如下:
|
||||
## 一、概念主题最新动态
|
||||
## 二、核心驱动与催化(政策/产业/事件)
|
||||
## 三、受益标的梳理(关联个股及逻辑)
|
||||
## 四、市场情绪与资金动向
|
||||
## 五、风险提示
|
||||
## 关注要点(3-5条)
|
||||
|
||||
分析须严格基于资讯,避免编造。impact_score >=65 视为重大变化。"""
|
||||
try:
|
||||
reply = llm_chat([
|
||||
{"role": "system", "content": "你是一名严谨专业的题材与概念跟踪分析师。"},
|
||||
{"role": "user", "content": prompt},
|
||||
]).strip()
|
||||
if not reply:
|
||||
raise RuntimeError("LLM 返回为空")
|
||||
judge = parse_judge(reply)
|
||||
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": len(db_news), "rag": len(rag_hits), "related": len(related)},
|
||||
"keywords": kws,
|
||||
}
|
||||
sources = {"keywords": kws, "db_news": db_text, "rag_news": rag_text,
|
||||
"related": related_txt}
|
||||
cid = f"CONCEPT:{name}"
|
||||
execute(
|
||||
"INSERT INTO tracking_reports(target_type, code, stock_name, industry, report, meta, sources, status, created_at) "
|
||||
"VALUES('concept',?,?,?,?,?,?,'done',datetime('now','localtime'))",
|
||||
(cid, name, "概念/主题", 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, {"name": name, "code": cid}, meta)
|
||||
return {"ok": True, "report_id": rid, "meta": meta}
|
||||
except Exception as e:
|
||||
log.exception("track concept %s fail", name)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# ===================================================================== 目标管理
|
||||
def list_targets():
|
||||
return query("SELECT * FROM watch_targets ORDER BY type, id")
|
||||
|
||||
|
||||
def add_target(ttype, name, code="", keywords=""):
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return {"error": "名称不能为空"}
|
||||
execute("INSERT INTO watch_targets(type, code, name, keywords) VALUES(?,?,?,?)",
|
||||
(ttype, code, name, keywords))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def delete_target(tid):
|
||||
execute("DELETE FROM watch_targets WHERE id=?", (tid,))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ===================================================================== 批量与调度
|
||||
def track_watchlist(progress=None):
|
||||
"""串行跟踪自选股(持仓)。同一时刻只允许一个跟踪任务(防重复)"""
|
||||
def track_all(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": "自选股为空,请先在股票池添加"}
|
||||
targets = []
|
||||
# 持仓(自选股)
|
||||
for w in query("SELECT w.code, s.name FROM watchlist w JOIN stocks s ON s.code=w.code ORDER BY w.added_at"):
|
||||
targets.append(("stock", w["code"], w["name"]))
|
||||
# 目标(股票/概念)
|
||||
for t in query("SELECT id, type, code, name, keywords FROM watch_targets WHERE enabled=1 ORDER BY id"):
|
||||
if t["type"] == "stock" and t["code"]:
|
||||
targets.append(("stock", t["code"], t["name"]))
|
||||
else:
|
||||
targets.append(("concept", t["name"], t["keywords"] or t["name"]))
|
||||
# 去重
|
||||
seen, uniq = set(), []
|
||||
for typ, key, name in targets:
|
||||
u = (typ, key)
|
||||
if u in seen:
|
||||
continue
|
||||
seen.add(u)
|
||||
uniq.append((typ, key, name))
|
||||
if not uniq:
|
||||
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"])
|
||||
for i, (typ, key, name) in enumerate(uniq):
|
||||
if typ == "stock":
|
||||
r = track_stock(key)
|
||||
else:
|
||||
r = track_concept(key, name)
|
||||
results.append({"type": typ, "name": name, **r})
|
||||
set_tracking_state(last_run=time.strftime("%Y-%m-%d %H:%M:%S"), last_stock=name)
|
||||
if progress:
|
||||
progress(i + 1, len(stocks))
|
||||
progress(i + 1, len(uniq))
|
||||
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 "
|
||||
return query("SELECT id, target_type, 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 "
|
||||
return query("SELECT id, target_type, code, stock_name, industry, meta, status, created_at "
|
||||
"FROM tracking_reports ORDER BY id DESC LIMIT ?", (limit,))
|
||||
|
||||
|
||||
@@ -371,7 +512,7 @@ class TrackingThread(threading.Thread):
|
||||
continue
|
||||
if cfg["enabled"]:
|
||||
try:
|
||||
r = track_watchlist()
|
||||
r = track_all()
|
||||
log.info("tracking cycle: %s", r)
|
||||
except Exception as e:
|
||||
log.warning("tracking cycle error: %s", e)
|
||||
|
||||
+17
-3
@@ -30,11 +30,25 @@ STRONG_WORDS = ["回购", "中标", "减持", "问询", "停牌", "重组", "预
|
||||
|
||||
|
||||
# ===================================================================== 邮件
|
||||
def send_email(subject, html_body, to=None, cfg=None, sender_name=None):
|
||||
"""发送 HTML 邮件。cfg 来自设置;失败抛异常(调用方捕获)"""
|
||||
def send_email(subject, html_body, to=None, cfg=None, sender_name=None, attachments=None):
|
||||
"""发送 HTML 邮件。cfg 来自设置;attachments: [{filename, content(bytes)}];失败抛异常"""
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.base import MIMEBase
|
||||
from email import encoders
|
||||
cfg = cfg or mail_config()
|
||||
to = to or cfg["email_to"]
|
||||
msg = MIMEText(html_body, "html", "utf-8")
|
||||
if attachments:
|
||||
msg = MIMEMultipart()
|
||||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
for att in attachments:
|
||||
part = MIMEBase("application", "octet-stream")
|
||||
part.set_payload(att.get("content") or b"")
|
||||
encoders.encode_base64(part)
|
||||
part.add_header("Content-Disposition", "attachment",
|
||||
filename=("utf-8", "", att.get("filename", "report.html")))
|
||||
msg.attach(part)
|
||||
else:
|
||||
msg = MIMEText(html_body, "html", "utf-8")
|
||||
msg["From"] = formataddr((sender_name or cfg["sender_name"], cfg["smtp_user"]))
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subject
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
每日行情报告引擎(盘前 / 盘后)
|
||||
- premarket : 工作日 9:00 —— 昨日市场回顾 / 昨日至今要闻 / 全球市场 / 持仓与关注目标 / 盘前研判
|
||||
- postmarket : 交易日 15:30 —— 今日市场总结 / 今日要闻 / 全球市场 / 持仓表现 / 盘后研判
|
||||
每期输出两份报告:
|
||||
简单版 —— 邮件正文(HTML,快速浏览)
|
||||
详细版 —— HTML 附件(完整结构化 + AI 深度解读)
|
||||
"""
|
||||
import datetime as dt
|
||||
import html as html_mod
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from database import query, query_one, execute
|
||||
from settings import mail_config
|
||||
from engine.analyst import llm_chat
|
||||
|
||||
log = logging.getLogger("report")
|
||||
|
||||
GLOBAL_ORDER = ["dji", "nasdaq", "sp500", "hsi", "nikkei", "kospi", "dax", "cac", "ftse"]
|
||||
KIND_META = {
|
||||
"premarket": {"name": "盘前分析", "scope": "昨日与今日", "title": "盘前 · 昨日市场回顾与今日展望"},
|
||||
"postmarket": {"name": "盘后总结", "scope": "今日", "title": "盘后 · 今日市场总结"},
|
||||
}
|
||||
|
||||
|
||||
# ===================================================================== 数据采集
|
||||
def latest_trading_day():
|
||||
r = query_one("SELECT MAX(date) d FROM stock_daily")
|
||||
return r["d"] if r else dt.date.today().isoformat()
|
||||
|
||||
|
||||
def collect_market(day):
|
||||
"""指数 / 涨跌 / 量能 / 行业 / 个股"""
|
||||
idx = query("SELECT * FROM market_index WHERE date<=? ORDER BY date DESC LIMIT 2", (day,))
|
||||
latest = idx[0] if idx else {}
|
||||
prev = idx[1] if len(idx) > 1 else latest
|
||||
inds = []
|
||||
for k, label in (("sh", "上证指数"), ("sz", "深证成指"), ("cy", "创业板指")):
|
||||
cur, old = latest.get(k, 0), prev.get(k, 0) or 1
|
||||
inds.append({"key": k, "label": label, "value": cur,
|
||||
"chg": round((cur - old) / old * 100, 2)})
|
||||
stat = query_one(
|
||||
"SELECT COUNT(*) total, SUM(CASE WHEN change_pct>0 THEN 1 ELSE 0 END) up,"
|
||||
"SUM(CASE WHEN change_pct<0 THEN 1 ELSE 0 END) down,"
|
||||
"SUM(CASE WHEN change_pct>=9.8 THEN 1 ELSE 0 END) limit_up,"
|
||||
"SUM(CASE WHEN change_pct<=-9.8 THEN 1 ELSE 0 END) limit_down,"
|
||||
"ROUND(SUM(amount)/10000,2) amount_yi "
|
||||
"FROM stock_daily WHERE date=?", (day,))
|
||||
heat = query(
|
||||
"SELECT s.industry, ROUND(AVG(d.change_pct),2) chg, COUNT(*) cnt "
|
||||
"FROM stock_daily d JOIN stocks s ON s.code=d.code WHERE d.date=? "
|
||||
"GROUP BY s.industry ORDER BY chg DESC", (day,))
|
||||
gainers = query(
|
||||
"SELECT s.name, s.code, s.industry, d.change_pct FROM stock_daily d "
|
||||
"JOIN stocks s ON s.code=d.code WHERE d.date=? ORDER BY d.change_pct DESC LIMIT 8", (day,))
|
||||
losers = query(
|
||||
"SELECT s.name, s.code, s.industry, d.change_pct FROM stock_daily d "
|
||||
"JOIN stocks s ON s.code=d.code WHERE d.date=? ORDER BY d.change_pct ASC LIMIT 8", (day,))
|
||||
return {"date": day, "indexes": inds, "stat": stat, "heat": heat,
|
||||
"gainers": gainers, "losers": losers}
|
||||
|
||||
|
||||
def collect_news(since_date, limit=20):
|
||||
rows = query(
|
||||
"SELECT id,title,content,source,category,publish_date,sentiment,related_stocks FROM news "
|
||||
"WHERE publish_date>=? ORDER BY publish_date DESC, id DESC LIMIT ?", (since_date, limit))
|
||||
# 按重要度排序(类别权重 + 情感强度)
|
||||
w = {"公司": 3, "业绩": 3, "机构观点": 2, "行业": 2, "市场": 1}
|
||||
for n in rows:
|
||||
n["_score"] = w.get(n["category"], 1) * 10 + abs(n["sentiment"]) * 5
|
||||
rows.sort(key=lambda x: x["_score"], reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
def collect_positions():
|
||||
rows = query(
|
||||
"SELECT w.code, s.name, s.industry, s.market_cap, d.close, d.change_pct "
|
||||
"FROM watchlist w JOIN stocks s ON s.code=w.code "
|
||||
"LEFT JOIN stock_daily d ON d.code=s.code AND d.date=(SELECT MAX(date) FROM stock_daily) "
|
||||
"ORDER BY w.added_at")
|
||||
out = []
|
||||
for r in rows:
|
||||
sc = query_one(
|
||||
"SELECT AVG(sentiment) s FROM news WHERE (related_stocks=? OR related_stocks LIKE ? OR related_stocks LIKE ?) "
|
||||
"AND publish_date>=date('now','-7 day')", (r["code"], f"%,{r['code']}", f"{r['code']},%"))
|
||||
out.append({**r, "news_score": round(sc["s"], 2) if sc and sc["s"] is not None else 0})
|
||||
return out
|
||||
|
||||
|
||||
def collect_targets():
|
||||
tgts = query("SELECT id, type, code, name, keywords FROM watch_targets WHERE enabled=1")
|
||||
out = []
|
||||
for t in tgts:
|
||||
if t["type"] == "stock" and t["code"]:
|
||||
latest = query_one(
|
||||
"SELECT meta, created_at FROM tracking_reports WHERE code=? ORDER BY id DESC LIMIT 1", (t["code"],))
|
||||
else:
|
||||
latest = query_one(
|
||||
"SELECT meta, created_at FROM tracking_reports WHERE code=? ORDER BY id DESC LIMIT 1",
|
||||
(f"CONCEPT:{t['name']}",))
|
||||
m = json.loads(latest["meta"]) if latest else {}
|
||||
out.append({"type": t["type"], "name": t["name"],
|
||||
"impact": m.get("impact_score"), "change_kind": m.get("change_kind"),
|
||||
"summary": m.get("summary", ""), "tracked_at": latest["created_at"] if latest else None})
|
||||
return out
|
||||
|
||||
|
||||
def collect_global():
|
||||
r = query_one("SELECT date, data FROM global_markets ORDER BY date DESC LIMIT 1")
|
||||
if not r:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(r["data"])
|
||||
except Exception:
|
||||
return []
|
||||
items = []
|
||||
for k in GLOBAL_ORDER:
|
||||
if k in data:
|
||||
items.append(data[k])
|
||||
return items
|
||||
|
||||
|
||||
# ===================================================================== 文本渲染
|
||||
def fmt_market(mkt):
|
||||
s = mkt["stat"] or {}
|
||||
idx_txt = " ".join(f"{i['label']} {i['value']:.2f} ({i['chg']:+.2f}%)" for i in mkt["indexes"])
|
||||
heat_txt = "、".join(f"{h['industry']}({h['chg']:+.2f}%)" for h in mkt["heat"][:6]) or "无"
|
||||
g_txt = "、".join(f"{g['name']}({g['change_pct']:+.2f}%)" for g in mkt["gainers"][:5])
|
||||
l_txt = "、".join(f"{g['name']}({g['change_pct']:+.2f}%)" for g in mkt["losers"][:5])
|
||||
return {
|
||||
"idx": idx_txt,
|
||||
"breadth": (f"上涨 {s.get('up',0)} / 下跌 {s.get('down',0)} 家,"
|
||||
f"涨停 {s.get('limit_up',0)} / 跌停 {s.get('limit_down',0)},"
|
||||
f"两市成交 {s.get('amount_yi',0)} 亿"),
|
||||
"heat": heat_txt,
|
||||
"gainers": g_txt or "无",
|
||||
"losers": l_txt or "无",
|
||||
}
|
||||
|
||||
|
||||
def fmt_news(news, top=8):
|
||||
lines = []
|
||||
for n in news[:top]:
|
||||
tone = "利好" if n["sentiment"] > 0 else ("利空" if n["sentiment"] < 0 else "中性")
|
||||
lines.append(f"- [{n['publish_date']}] {n['title']}({n['category']}·{tone}{n['sentiment']:+.2f}){n['content'][:60]}")
|
||||
return "\n".join(lines) or "(暂无)"
|
||||
|
||||
|
||||
def fmt_positions(pos):
|
||||
if not pos:
|
||||
return "(当前无持仓/自选股)"
|
||||
return "\n".join(
|
||||
f"- {p['name']}({p['code']}) {p['industry']} 收盘{p['close']} ({p['change_pct']:+.2f}%) 市值{p['market_cap']:.0f}亿 近7日消息面{p['news_score']:+.2f}"
|
||||
for p in pos)
|
||||
|
||||
|
||||
def fmt_targets(tgts):
|
||||
if not tgts:
|
||||
return "(当前无跟踪目标)"
|
||||
return "\n".join(
|
||||
f"- [{t['type']}] {t['name']} 影响度{t['impact'] or '--'}/100 {t['change_kind'] or ''} {t['summary'][:50]}"
|
||||
for t in tgts)
|
||||
|
||||
|
||||
def fmt_global(items):
|
||||
return " ".join(f"{g.get('label','')} {g.get('value',0):.2f} ({g.get('chg',0):+.2f}%)" for g in items) or "(暂无)"
|
||||
|
||||
|
||||
# ===================================================================== 生成报告
|
||||
def _build_context(kind):
|
||||
day = latest_trading_day()
|
||||
mkt = collect_market(day)
|
||||
fm = fmt_market(mkt)
|
||||
if kind == "premarket":
|
||||
news = collect_news(day, limit=24)
|
||||
scope_txt = "昨日/最近交易日"
|
||||
else:
|
||||
news = collect_news(day, limit=24)
|
||||
scope_txt = "今日"
|
||||
pos = collect_positions()
|
||||
tgts = collect_targets()
|
||||
glob = collect_global()
|
||||
ctx = {
|
||||
"kind_name": KIND_META[kind]["name"],
|
||||
"date": day,
|
||||
"scope": scope_txt,
|
||||
"mkt": mkt, "fm": fm,
|
||||
"news": news, "news_txt": fmt_news(news, 10),
|
||||
"pos": pos, "pos_txt": fmt_positions(pos),
|
||||
"tgts": tgts, "tgts_txt": fmt_targets(tgts),
|
||||
"global_txt": fmt_global(glob),
|
||||
"global": glob,
|
||||
}
|
||||
return ctx
|
||||
|
||||
|
||||
def _base_prompt(ctx, detailed):
|
||||
d = ctx["date"]
|
||||
title = KIND_META[ctx["kind_name"] if ctx["kind_name"] in KIND_META else "premarket"]["title"] if False else ""
|
||||
kind = "盘前分析" if "盘前" in ctx["kind_name"] else "盘后总结"
|
||||
return f"""你是资深A股市场分析师,请基于下方【数据】生成一份{kind}报告。
|
||||
|
||||
【报告日期】{d}
|
||||
【指数】{ctx['fm']['idx']}
|
||||
【涨跌结构】{ctx['fm']['breadth']}
|
||||
【领涨行业】{ctx['fm']['heat']}
|
||||
【领涨个股】{ctx['fm']['gainers']}
|
||||
【领跌个股】{ctx['fm']['losers']}
|
||||
【重点要闻】
|
||||
{ctx['news_txt']}
|
||||
【全球市场】
|
||||
{ctx['global_txt']}
|
||||
【持仓/自选股】
|
||||
{ctx['pos_txt']}
|
||||
【关注目标/主题】
|
||||
{ctx['tgts_txt']}
|
||||
"""
|
||||
|
||||
|
||||
def _brief_prompt(ctx):
|
||||
return _base_prompt(ctx, False) + """
|
||||
【输出要求】输出一份精炼的盘前/盘后速览(约 200-300 字),Markdown 格式,包含:
|
||||
1. 一句话大盘研判
|
||||
2. 3-5 条关键要点(行情/消息/持仓/主题)
|
||||
3. 今日关注提示
|
||||
要求信息密集、数据准确,不要编造数据。"""
|
||||
|
||||
|
||||
def _detail_prompt(ctx):
|
||||
return _base_prompt(ctx, True) + """
|
||||
【输出要求】输出一份完整的盘前/盘后分析报告(Markdown),结构如下:
|
||||
## 一、市场概览(指数表现/涨跌结构/量能/领涨领跌板块个股解读)
|
||||
## 二、消息面解析(分类解读重点要闻及影响:政策/行业/公司/机构观点)
|
||||
## 三、全球市场联动(外围市场表现及对A股的传导)
|
||||
## 四、持仓表现(逐只点评:涨跌、评分依据、近期消息面)
|
||||
## 五、关注目标/主题(各主题/个股的最新动态与影响度解读)
|
||||
## 六、操作策略与风险提示
|
||||
数据须严格来自上文【数据】,可补充合理分析逻辑,不得编造数字。"""
|
||||
|
||||
|
||||
def generate_reports(kind):
|
||||
"""生成 (brief_html, detail_html)"""
|
||||
ctx = _build_context(kind)
|
||||
brief_md = ""
|
||||
detail_md = ""
|
||||
try:
|
||||
brief_md = llm_chat([
|
||||
{"role": "system", "content": "你是一名严谨专业的A股市场分析师。"},
|
||||
{"role": "user", "content": _brief_prompt(ctx)},
|
||||
]).strip()
|
||||
except Exception as e:
|
||||
log.warning("brief llm fail: %s", e)
|
||||
try:
|
||||
detail_md = llm_chat([
|
||||
{"role": "system", "content": "你是一名严谨专业的A股市场分析师。"},
|
||||
{"role": "user", "content": _detail_prompt(ctx)},
|
||||
]).strip()
|
||||
except Exception as e:
|
||||
log.warning("detail llm fail: %s", e)
|
||||
|
||||
brief_html = _render_brief(ctx, brief_md)
|
||||
detail_html = _render_detail(ctx, detail_md)
|
||||
return brief_html, detail_html
|
||||
|
||||
|
||||
# ===================================================================== 渲染
|
||||
def _render_brief(ctx, brief_md):
|
||||
kind = KIND_META[ctx["kind_name"] if ctx["kind_name"] in KIND_META else "premarket"]
|
||||
rows = []
|
||||
for i in ctx["mkt"]["indexes"]:
|
||||
rows.append(f"<b style='color:{'#e03e3e' if i['chg']>=0 else '#17a34a'}'>{i['label']} {i['value']:.2f} ({i['chg']:+.2f}%)</b>")
|
||||
news_li = "".join(f"<li>[{n['publish_date']}] {html_mod.escape(n['title'])} <span style='color:#888'>({n['category']})</span></li>"
|
||||
for n in ctx["news"][:6]) or "<li>暂无</li>"
|
||||
pos_li = "".join(f"<li><b>{p['name']}</b>({p['code']}) 收{p['close']} "
|
||||
f"<b style='color:{'#e03e3e' if p['change_pct']>=0 else '#17a34a'}'>{p['change_pct']:+.2f}%</b> · {p['industry']}</li>"
|
||||
for p in ctx["pos"]) or "<li>暂无持仓</li>"
|
||||
tgt_li = "".join(f"<li>{html_mod.escape(t['name'])}(影响度{t['impact'] or '--'},{t['change_kind'] or '—'})</li>"
|
||||
for t in ctx["tgts"][:5]) or "<li>暂无目标</li>"
|
||||
gb = " | ".join(f"{g.get('label','')} {g.get('value',0):.2f} "
|
||||
f"<b style='color:{'#e03e3e' if g.get('chg',0)>=0 else '#17a34a'}'>({g.get('chg',0):+.2f}%)</b>" for g in ctx["global"][:6])
|
||||
ai = html_mod.escape(brief_md) if brief_md else "(AI 简评生成失败,请查看附件详细版)"
|
||||
return f"""<html><body style="font-family:Microsoft YaHei,Arial;background:#f5f6f8;padding:20px;">
|
||||
<div style="max-width:680px;margin:auto;background:#fff;border-radius:8px;border:1px solid #e5e7eb;overflow:hidden;">
|
||||
<div style="background:#1e293b;color:#fff;padding:16px 22px;">
|
||||
<div style="font-size:20px;font-weight:bold;">📊 智能荐股 · {kind['name']}</div>
|
||||
<div style="font-size:12px;opacity:.8;margin-top:4px;">{ctx['date']} · 简版速览 · 详细版见附件</div>
|
||||
</div>
|
||||
<div style="padding:18px 22px;">
|
||||
<div style="font-size:15px;color:#333;margin-bottom:6px;">🔎 大盘:</div>
|
||||
<div style="font-size:15px;">{' '.join(rows)}</div>
|
||||
<div style="color:#666;font-size:13px;margin-top:4px;">{ctx['fm']['breadth']}</div>
|
||||
<div style="color:#666;font-size:13px;margin-top:4px;"><b>领涨行业:</b>{ctx['fm']['heat']}</div>
|
||||
<div style="margin:14px 0;border-top:1px solid #eee;"></div>
|
||||
<div style="font-size:14px;color:#333;margin-bottom:6px;">📰 重点要闻:</div>
|
||||
<ul style="color:#444;font-size:13px;padding-left:20px;line-height:1.8;">{news_li}</ul>
|
||||
<div style="margin:14px 0;border-top:1px solid #eee;"></div>
|
||||
<div style="font-size:14px;color:#333;margin-bottom:6px;">🌏 全球市场:</div>
|
||||
<div style="color:#444;font-size:13px;">{gb}</div>
|
||||
<div style="margin:14px 0;border-top:1px solid #eee;"></div>
|
||||
<div style="font-size:14px;color:#333;margin-bottom:6px;">💼 持仓:</div>
|
||||
<ul style="color:#444;font-size:13px;padding-left:20px;line-height:1.8;">{pos_li}</ul>
|
||||
<div style="margin:14px 0;border-top:1px solid #eee;"></div>
|
||||
<div style="font-size:14px;color:#333;margin-bottom:6px;">🎯 关注目标/主题:</div>
|
||||
<ul style="color:#444;font-size:13px;padding-left:20px;line-height:1.8;">{tgt_li}</ul>
|
||||
<div style="margin:14px 0;border-top:1px solid #eee;"></div>
|
||||
<div style="font-size:14px;color:#333;margin-bottom:6px;">🤖 AI 研判:</div>
|
||||
<div style="color:#333;font-size:13px;line-height:1.8;white-space:pre-wrap;">{ai}</div>
|
||||
</div>
|
||||
<div style="background:#f8fafc;padding:10px 22px;color:#94a3b8;font-size:11px;text-align:center;">
|
||||
智能荐股系统自动生成 · 内容基于模拟数据,仅供演示,不构成投资建议
|
||||
</div></div></body></html>"""
|
||||
|
||||
|
||||
def _md_to_html(md):
|
||||
"""极简 Markdown → HTML(用于附件详细版)"""
|
||||
md = html_mod.escape(md or "")
|
||||
out, in_list = [], False
|
||||
for line in md.splitlines():
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
if in_list:
|
||||
out.append("</ul>"); in_list = False
|
||||
continue
|
||||
if line.startswith("## "):
|
||||
if in_list:
|
||||
out.append("</ul>"); in_list = False
|
||||
out.append(f"<h2>{line[3:]}</h2>")
|
||||
elif line.startswith("### "):
|
||||
if in_list:
|
||||
out.append("</ul>"); in_list = False
|
||||
out.append(f"<h3>{line[4:]}</h3>")
|
||||
elif line.startswith("##"):
|
||||
if in_list:
|
||||
out.append("</ul>"); in_list = False
|
||||
out.append(f"<h2>{line[2:].strip()}</h2>")
|
||||
elif line.startswith("- "):
|
||||
if not in_list:
|
||||
out.append("<ul>"); in_list = True
|
||||
out.append(f"<li>{line[2:]}</li>")
|
||||
elif line.startswith("# "):
|
||||
if in_list:
|
||||
out.append("</ul>"); in_list = False
|
||||
out.append(f"<h1>{line[2:]}</h1>")
|
||||
else:
|
||||
if in_list:
|
||||
out.append("</ul>"); in_list = False
|
||||
out.append(f"<p>{line}</p>")
|
||||
if in_list:
|
||||
out.append("</ul>")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _render_detail(ctx, detail_md):
|
||||
kind = KIND_META[ctx["kind_name"] if ctx["kind_name"] in KIND_META else "premarket"]
|
||||
idx_rows = "".join(
|
||||
f"<tr><td>{i['label']}</td><td>{i['value']:.2f}</td>"
|
||||
f"<td style='color:{'#e03e3e' if i['chg']>=0 else '#17a34a'}'>{i['chg']:+.2f}%</td></tr>"
|
||||
for i in ctx["mkt"]["indexes"])
|
||||
stat = ctx["mkt"]["stat"] or {}
|
||||
heat_rows = "".join(f"<tr><td>{h['industry']}</td><td>{h['cnt']}</td>"
|
||||
f"<td style='color:{'#e03e3e' if h['chg']>=0 else '#17a34a'}'>{h['chg']:+.2f}%</td></tr>"
|
||||
for h in ctx["mkt"]["heat"])
|
||||
g_rows = "".join(f"<tr><td>{g['name']}</td><td>{g['code']}</td>"
|
||||
f"<td style='color:{'#e03e3e' if g['change_pct']>=0 else '#17a34a'}'>{g['change_pct']:+.2f}%</td></tr>"
|
||||
for g in ctx["mkt"]["gainers"])
|
||||
l_rows = "".join(f"<tr><td>{g['name']}</td><td>{g['code']}</td>"
|
||||
f"<td style='color:{'#e03e3e' if g['change_pct']>=0 else '#17a34a'}'>{g['change_pct']:+.2f}%</td></tr>"
|
||||
for g in ctx["mkt"]["losers"])
|
||||
news_rows = "".join(
|
||||
f"<tr><td>{n['publish_date']}</td><td>{n['category']}</td><td>{html_mod.escape(n['title'])}</td>"
|
||||
f"<td style='color:{'#e03e3e' if n['sentiment']>=0 else '#17a34a'}'>{n['sentiment']:+.2f}</td></tr>"
|
||||
for n in ctx["news"][:15])
|
||||
pos_rows = "".join(
|
||||
f"<tr><td><b>{p['name']}</b>{p['code']}</td><td>{p['industry']}</td><td>{p['close']}</td>"
|
||||
f"<td style='color:{'#e03e3e' if p['change_pct']>=0 else '#17a34a'}'>{p['change_pct']:+.2f}%</td>"
|
||||
f"<td>{p['market_cap']:.0f}亿</td><td>{p['news_score']:+.2f}</td></tr>"
|
||||
for p in ctx["pos"]) or "<tr><td colspan='6'>暂无持仓</td></tr>"
|
||||
gb_rows = "".join(f"<tr><td>{g.get('label','')}</td><td>{g.get('value',0):.2f}</td>"
|
||||
f"<td style='color:{'#e03e3e' if g.get('chg',0)>=0 else '#17a34a'}'>{g.get('chg',0):+.2f}%</td></tr>"
|
||||
for g in ctx["global"])
|
||||
body = _md_to_html(detail_md) if detail_md else "<p>(AI 分析生成失败)</p>"
|
||||
return f"""<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">
|
||||
<title>智能荐股 · {kind['name']} {ctx['date']}</title>
|
||||
<style>
|
||||
body{{font-family:Microsoft YaHei,Arial,sans-serif;background:#f5f6f8;padding:24px;color:#333;line-height:1.8;}}
|
||||
.wrap{{max-width:820px;margin:auto;background:#fff;border:1px solid #e5e7eb;border-radius:10px;overflow:hidden;}}
|
||||
.head{{background:#1e293b;color:#fff;padding:20px 28px;}}
|
||||
.head h1{{margin:0;font-size:22px;}}
|
||||
.head .sub{{font-size:12px;opacity:.8;margin-top:4px;}}
|
||||
.body{{padding:20px 28px;}}
|
||||
h2{{border-bottom:2px solid #eef2f7;padding-bottom:8px;margin-top:28px;color:#1e293b;font-size:18px;}}
|
||||
h3{{color:#334155;margin-top:18px;}}
|
||||
table{{width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;}}
|
||||
th,td{{border:1px solid #e5e7eb;padding:7px 10px;text-align:left;}}
|
||||
th{{background:#f8fafc;color:#475569;}}
|
||||
.up{{color:#e03e3e;}}.down{{color:#17a34a;}}
|
||||
.card{{background:#f8fafc;border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px;margin:12px 0;font-size:13px;}}
|
||||
.foot{{background:#f8fafc;padding:12px 28px;color:#94a3b8;font-size:11px;text-align:center;}}
|
||||
</style></head><body><div class="wrap">
|
||||
<div class="head">
|
||||
<h1>📊 智能荐股 · {kind['name']}({ctx['date']})</h1>
|
||||
<div class="sub">市场/要闻/全球/持仓/主题 全景分析 · 详细版报告 · 自动生成</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h2>〇、数据总览</h2>
|
||||
<div class="card"><b>指数</b><table><tr><th>指数</th><th>收盘</th><th>涨跌</th></tr>{idx_rows}</table>
|
||||
<b>涨跌结构</b>:{ctx['fm']['breadth']}</div>
|
||||
<div class="card"><b>行业热度</b><table><tr><th>行业</th><th>家数</th><th>平均涨跌</th></tr>{heat_rows}</table></div>
|
||||
<div class="card"><b>领涨个股</b><table><tr><th>名称</th><th>代码</th><th>涨跌</th></tr>{g_rows}</table>
|
||||
<b>领跌个股</b><table><tr><th>名称</th><th>代码</th><th>涨跌</th></tr>{l_rows}</table></div>
|
||||
<h2>重点要闻</h2>
|
||||
<table><tr><th>日期</th><th>分类</th><th>标题</th><th>情感</th></tr>{news_rows}</table>
|
||||
<h2>全球市场</h2>
|
||||
<table><tr><th>指数</th><th>点位</th><th>涨跌</th></tr>{gb_rows}</table>
|
||||
<h2>持仓 / 自选股</h2>
|
||||
<table><tr><th>股票</th><th>行业</th><th>收盘</th><th>涨跌</th><th>市值</th><th>消息面</th></tr>{pos_rows}</table>
|
||||
<h2>关注目标 / 主题</h2>
|
||||
{html_mod.escape(ctx['tgts_txt']).replace(chr(10), '<br>')}
|
||||
<h2>AI 深度分析</h2>
|
||||
{body}
|
||||
</div>
|
||||
<div class="foot">智能荐股系统自动生成 · 内容基于模拟数据,仅供演示,不构成投资建议</div>
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
# ===================================================================== 发送
|
||||
def send_daily_report(kind="premarket"):
|
||||
"""生成并发送报告:正文=简版,附件=详细版 HTML。返回 dict 状态"""
|
||||
from engine.notifier import send_email
|
||||
mc = mail_config()
|
||||
brief_html, detail_html = generate_reports(kind)
|
||||
meta = KIND_META.get(kind, KIND_META["premarket"])
|
||||
subject = f"[智能荐股] {meta['name']} {time.strftime('%Y-%m-%d')}"
|
||||
detail_file = f"智能荐股_{meta['name']}_{time.strftime('%Y%m%d')}.html"
|
||||
try:
|
||||
send_email(subject, brief_html, cfg=mc,
|
||||
attachments=[{"filename": detail_file, "content": detail_html.encode("utf-8")}])
|
||||
execute("INSERT INTO report_log(kind, subject, brief_len, detail_len, status, message) "
|
||||
"VALUES(?,?,?,?,'sent','附件: '||?)",
|
||||
(kind, subject, len(brief_html), len(detail_html), detail_file))
|
||||
return {"ok": True, "subject": subject, "detail_file": detail_file}
|
||||
except Exception as e:
|
||||
execute("INSERT INTO report_log(kind, subject, brief_len, detail_len, status, message) "
|
||||
"VALUES(?,?,?,?,'failed',?)",
|
||||
(kind, subject, len(brief_html), len(detail_html), str(e)))
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def report_log(limit=20):
|
||||
return query("SELECT * FROM report_log ORDER BY id DESC LIMIT ?", (limit,))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
kind = sys.argv[1] if len(sys.argv) > 1 else "premarket"
|
||||
if kind not in ("premarket", "postmarket"):
|
||||
kind = "premarket"
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
r = send_daily_report(kind)
|
||||
print(r)
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
定时报告 CLI 入口(配合 crontab 使用)
|
||||
用法:
|
||||
python3 reports.py premarket # 盘前分析(工作日 9:00)
|
||||
python3 reports.py postmarket # 盘后总结(交易日 15:30)
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, BASE_DIR)
|
||||
os.chdir(BASE_DIR)
|
||||
|
||||
from database import init_db
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
kind = sys.argv[1] if len(sys.argv) > 1 else "premarket"
|
||||
if kind not in ("premarket", "postmarket"):
|
||||
kind = "premarket"
|
||||
init_db()
|
||||
from engine.report import send_daily_report
|
||||
r = send_daily_report(kind)
|
||||
print(f"[{kind}] {r}")
|
||||
sys.exit(0 if r.get("ok") else 1)
|
||||
+32
-2
@@ -15,6 +15,7 @@
|
||||
"""
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
@@ -238,7 +239,8 @@ def gen_daily(dates):
|
||||
drift = {0: 0.0011, 1: 0.00025, 2: -0.00085}[trend]
|
||||
# 最近30天加速(制造趋势分化,让荐股有区分度)
|
||||
recent_drift = {0: 0.0045, 1: 0.0001, 2: -0.0045}[trend]
|
||||
base_vol = float_shares * 10000 * random.uniform(0.8, 2.2) # 基准成交量(万股)
|
||||
# 基准成交量(万股)= 流通盘 × 0.4%~1.5% 日换手(贴近真实市场)
|
||||
base_vol = float_shares * 10000 * random.uniform(0.004, 0.015)
|
||||
for i, d in enumerate(dates):
|
||||
phase = max(0, i - (len(dates) - 30))
|
||||
dr = drift + (recent_drift if phase > 0 else 0)
|
||||
@@ -250,7 +252,7 @@ def gen_daily(dates):
|
||||
open_p = prev * (1 + random.gauss(0, vol * 0.5))
|
||||
high = max(open_p, p) * (1 + abs(random.gauss(0, vol * 0.35)))
|
||||
low = min(open_p, p) * (1 - abs(random.gauss(0, vol * 0.35)))
|
||||
volume = base_vol * (1 + 3 * abs(r) / vol) * random.uniform(0.6, 1.4)
|
||||
volume = base_vol * (1 + 1.5 * abs(r) / vol) * random.uniform(0.6, 1.4)
|
||||
amount = volume * (open_p + p) / 2 # 万元
|
||||
chg = (p - prev) / prev * 100
|
||||
daily.append((code, d, round(open_p, 2), round(high, 2), round(low, 2),
|
||||
@@ -339,6 +341,30 @@ def _recent_days(n):
|
||||
return dates
|
||||
|
||||
|
||||
GLOBAL_INDICES = [
|
||||
("dji", "道琼斯", 34000), ("nasdaq", "纳斯达克", 12800), ("sp500", "标普500", 4400),
|
||||
("hsi", "恒生指数", 17500), ("nikkei", "日经225", 33000), ("kospi", "韩国KOSPI", 2500),
|
||||
("dax", "德国DAX", 16000), ("cac", "法国CAC40", 7000), ("ftse", "英国FTSE100", 7500),
|
||||
]
|
||||
|
||||
|
||||
def _gen_global(dates):
|
||||
"""生成全球主要指数模拟序列(随机游走,chg 基于前一交易日)"""
|
||||
vals = {k: v for k, _, v in GLOBAL_INDICES}
|
||||
prev = dict(vals)
|
||||
out = {}
|
||||
for d in dates:
|
||||
row = {}
|
||||
for k, label, _v in GLOBAL_INDICES:
|
||||
vals[k] *= (1 + random.gauss(0.0002, 0.009))
|
||||
row[k] = {"label": label, "value": round(vals[k], 2),
|
||||
"chg": round((vals[k] - prev[k]) / prev[k] * 100, 2)}
|
||||
for k in prev:
|
||||
prev[k] = vals[k]
|
||||
out[d] = row
|
||||
return out
|
||||
|
||||
|
||||
def gen_holdings(price, dates):
|
||||
"""基金季度持仓:2025Q4 / 2026Q1 / 2026Q2"""
|
||||
rows = []
|
||||
@@ -424,6 +450,10 @@ def main():
|
||||
"VALUES(?,?,?,?,?,?,?,?,?)", daily)
|
||||
executemany("INSERT OR REPLACE INTO market_index(date,sh,sz,cy) VALUES(?,?,?,?)",
|
||||
[(d, v["sh"], v["sz"], v["cy"]) for d, v in index.items()])
|
||||
# 全球市场指数(模拟)
|
||||
gm = _gen_global(dates)
|
||||
executemany("INSERT OR REPLACE INTO global_markets(date,data) VALUES(?,?)",
|
||||
[(d, json.dumps(v, ensure_ascii=False)) for d, v in gm.items()])
|
||||
# 回填市值
|
||||
for code, name, industry, board, base, fs, trend, vol, biz in STOCKS:
|
||||
from database import execute as ex
|
||||
|
||||
@@ -40,6 +40,34 @@ async function rebuildBt() {
|
||||
} catch (e) { toast('启动失败'); }
|
||||
}
|
||||
|
||||
/* 定时报告 */
|
||||
async function sendReport(kind) {
|
||||
const btn = event.target; btn.disabled = true;
|
||||
try {
|
||||
const r = await api('/api/report/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind }) });
|
||||
toast(r.msg || '已启动');
|
||||
setTimeout(loadReportLog, 60000);
|
||||
setTimeout(loadReportLog, 150000);
|
||||
} catch (e) { toast('启动失败:' + e.message); }
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
async function loadReportLog() {
|
||||
try {
|
||||
const d = await api('/api/report/log');
|
||||
const items = d.items || [];
|
||||
if (!items.length) return;
|
||||
$('#reportLogTb').innerHTML = items.map(r => `
|
||||
<tr>
|
||||
<td style="color:var(--muted)">${r.sent_at}</td>
|
||||
<td>${r.kind === 'premarket' ? '🌅 盘前' : '🌇 盘后'}</td>
|
||||
<td>${escapeHtml(r.subject)}</td>
|
||||
<td><span class="tag ${r.status === 'sent' ? 'tag-推荐' : 'tag-负'}">${r.status}</span></td>
|
||||
<td style="color:var(--text2);font-size:12px">${escapeHtml(r.message || '')}</td>
|
||||
</tr>`).join('');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function reseed() {
|
||||
if (!confirm('将清空全部业务数据并重新生成(含向量索引重建,需 1-3 分钟),确定继续?')) return;
|
||||
try {
|
||||
@@ -67,3 +95,4 @@ async function health() {
|
||||
}
|
||||
|
||||
refreshStats();
|
||||
loadReportLog();
|
||||
+120
-15
@@ -29,6 +29,8 @@ async function loadAuto() {
|
||||
renderTrackingState(tr);
|
||||
loadLog();
|
||||
loadTrackReports();
|
||||
loadPositions();
|
||||
loadTargets();
|
||||
} catch (e) { toast('加载失败'); }
|
||||
}
|
||||
|
||||
@@ -102,6 +104,95 @@ async function runTrackAll() {
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
/* ===================== 持仓(自选股)管理 ===================== */
|
||||
async function loadPositions() {
|
||||
try {
|
||||
const d = await api('/api/watchlist');
|
||||
const items = d.items || [];
|
||||
if (!items.length) {
|
||||
$('#posManage').innerHTML = '<div class="empty">暂无持仓。在下方搜索添加,或到 <a href="/stocks">股票池</a> / 个股详情页 ⭐ 添加</div>';
|
||||
return;
|
||||
}
|
||||
$('#posManage').innerHTML = `<table>
|
||||
<tr><th>股票</th><th>现价</th><th>今日</th><th>评分</th><th>操作</th></tr>
|
||||
${items.map(p => `<tr>
|
||||
<td><a href="/stock/${p.code}" target="_blank"><b>${p.name}</b></a><div style="color:var(--muted);font-size:12px">${p.code} · ${p.industry}</div></td>
|
||||
<td class="num">${p.close}</td>
|
||||
<td class="num ${pctClass(p.change_pct)}">${fmtPct(p.change_pct)}</td>
|
||||
<td class="num">${p.score}</td>
|
||||
<td><button class="btn btn-danger" onclick="removePos('${p.code}')">移除</button></td>
|
||||
</tr>`).join('')}
|
||||
</table>`;
|
||||
} catch (e) { $('#posManage').innerHTML = '<div class="empty">加载失败</div>'; }
|
||||
}
|
||||
|
||||
async function addPos() {
|
||||
const kw = $('#posSearch').value.trim();
|
||||
if (!kw) return;
|
||||
try {
|
||||
const d = await api('/api/stocks?keyword=' + encodeURIComponent(kw) + '&per=5');
|
||||
const items = d.items || [];
|
||||
if (!items.length) { toast('未找到该股票'); return; }
|
||||
// 精确匹配或取第一个
|
||||
const hit = items.find(x => x.code === kw || x.name === kw) || items[0];
|
||||
await api('/api/watchlist/' + hit.code, { method: 'POST' });
|
||||
toast(`已添加 ${hit.name} 为持仓`);
|
||||
$('#posSearch').value = '';
|
||||
loadPositions();
|
||||
} catch (e) { toast('添加失败'); }
|
||||
}
|
||||
|
||||
async function removePos(code) {
|
||||
try {
|
||||
await api('/api/watchlist/' + code, { method: 'DELETE' });
|
||||
toast('已移除持仓');
|
||||
loadPositions();
|
||||
} catch (e) { toast('操作失败'); }
|
||||
}
|
||||
|
||||
/* ===================== 概念/主题/股票目标管理 ===================== */
|
||||
async function loadTargets() {
|
||||
try {
|
||||
const d = await api('/api/targets');
|
||||
const items = d.items || [];
|
||||
if (!items.length) {
|
||||
$('#tgtManage').innerHTML = '<div class="empty">暂无跟踪目标,添加概念/主题(如 AI算力、低空经济)或个股目标</div>';
|
||||
return;
|
||||
}
|
||||
$('#tgtManage').innerHTML = `<table>
|
||||
<tr><th>类型</th><th>目标</th><th>检索关键词</th><th>状态</th><th>操作</th></tr>
|
||||
${items.map(t => `<tr>
|
||||
<td><span class="cat-tag ${t.type === 'concept' ? '行业' : '公司'}">${t.type === 'concept' ? '概念/主题' : '股票'}</span></td>
|
||||
<td><b>${escapeHtml(t.name)}</b>${t.code ? `<div style="color:var(--muted);font-size:12px">${t.code}</div>` : ''}</td>
|
||||
<td style="color:var(--text2)">${escapeHtml(t.keywords || '—')}</td>
|
||||
<td>${t.enabled ? '<span class="tag tag-推荐">跟踪中</span>' : '<span class="tag tag-观望">停用</span>'}</td>
|
||||
<td><button class="btn btn-danger" onclick="removeTarget(${t.id})">移除</button></td>
|
||||
</tr>`).join('')}
|
||||
</table>`;
|
||||
} catch (e) { $('#tgtManage').innerHTML = '<div class="empty">加载失败</div>'; }
|
||||
}
|
||||
|
||||
async function addTarget() {
|
||||
const type = $('#tgtType').value;
|
||||
const name = $('#tgtName').value.trim();
|
||||
const keywords = $('#tgtKeywords').value.trim();
|
||||
if (!name) { toast('请输入目标名称'); return; }
|
||||
try {
|
||||
await api('/api/targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type, name, keywords }) });
|
||||
toast('已添加跟踪目标');
|
||||
$('#tgtName').value = ''; $('#tgtKeywords').value = '';
|
||||
loadTargets();
|
||||
} catch (e) { toast('添加失败:' + e.message); }
|
||||
}
|
||||
|
||||
async function removeTarget(id) {
|
||||
try {
|
||||
await api('/api/targets/' + id, { method: 'DELETE' });
|
||||
toast('已移除目标');
|
||||
loadTargets();
|
||||
} catch (e) { toast('操作失败'); }
|
||||
}
|
||||
|
||||
/* ===================== 日志与报告 ===================== */
|
||||
async function loadLog() {
|
||||
try {
|
||||
@@ -129,12 +220,14 @@ async function loadTrackReports() {
|
||||
let meta = {};
|
||||
try { meta = JSON.parse(r.meta || '{}'); } catch (e) {}
|
||||
const sigCls = meta.impact_score >= 65 ? 'tag-强烈推荐' : meta.impact_score >= 45 ? 'tag-推荐' : 'tag-关注';
|
||||
const isConcept = r.target_type === 'concept';
|
||||
return `<tr class="row-link" onclick="showTrack(${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><span class="cat-tag ${isConcept ? '行业' : '公司'}">${isConcept ? '概念' : '股票'}</span></td>
|
||||
<td><b>${r.stock_name}</b>${isConcept ? '' : `<div style="color:var(--muted);font-size:12px">${r.code}</div>`}</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 style="max-width:300px;white-space:normal;color:var(--text2)">${escapeHtml(meta.summary || '')}</td>
|
||||
<td><button class="btn" onclick="event.stopPropagation();showTrack(${r.id})">查看 ↗</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -146,16 +239,31 @@ async function showTrack(id) {
|
||||
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>`;
|
||||
};
|
||||
const isConcept = d.target_type === 'concept';
|
||||
// 概念报告:展示关键词/资讯/受益个股;股票报告:四环节数据源
|
||||
let sourceHtml = '';
|
||||
if (isConcept) {
|
||||
const dc = (meta.news_counts || {}).direct || 0, rc = (meta.news_counts || {}).rag || 0, nc = (meta.news_counts || {}).related || 0;
|
||||
sourceHtml = `
|
||||
<details open><summary>🔎 相关资讯(${dc} 条)</summary><div class="src-body"><pre class="src-pre">${escapeHtml(src.db_news || '')}</pre></div></details>
|
||||
<details><summary>🧠 语义检索命中(${rc} 条)</summary><div class="src-body"><pre class="src-pre">${escapeHtml(src.rag_news || '')}</pre></div></details>
|
||||
<details><summary>🏢 受益个股(${nc} 只)</summary><div class="src-body"><pre class="src-pre">${escapeHtml(src.related || '')}</pre></div></details>`;
|
||||
} else {
|
||||
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>`;
|
||||
};
|
||||
sourceHtml = newsBlock('个股直接动态', '📄', src.direct) +
|
||||
newsBlock('上游产业链(供给/成本)', '⬆️', src.upstream) +
|
||||
newsBlock('下游产业链(需求/景气)', '⬇️', src.downstream) +
|
||||
newsBlock('同业竞争', '🏢', src.peers);
|
||||
}
|
||||
openModal(`
|
||||
<h3>🧭 产业链跟踪:${d.stock_name}(${d.code})</h3>
|
||||
<h3>${isConcept ? '🎯 概念主题跟踪' : '🧭 产业链跟踪'}:${d.stock_name}${d.code && !isConcept ? '(' + d.code + ')' : ''}</h3>
|
||||
<div class="news-meta" style="margin-bottom:12px">
|
||||
<span class="cat-tag">${escapeHtml(d.industry)}</span>
|
||||
<span class="cat-tag ${isConcept ? '行业' : '公司'}">${isConcept ? '概念/主题' : 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>
|
||||
@@ -163,14 +271,11 @@ async function showTrack(id) {
|
||||
<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)}
|
||||
${sourceHtml}
|
||||
</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>
|
||||
${isConcept ? '' : `<a class="btn btn-primary" href="/stock/${d.code}" target="_blank">查看个股 →</a>`}
|
||||
</div>
|
||||
`);
|
||||
} catch (e) { toast('加载失败'); }
|
||||
|
||||
+22
-9
@@ -68,9 +68,11 @@ function renderRecords(items) {
|
||||
let meta = {};
|
||||
try { meta = JSON.parse(r.meta || '{}'); } catch (e) {}
|
||||
const sigCls = meta.impact_score >= 65 ? 'tag-强烈推荐' : meta.impact_score >= 45 ? 'tag-推荐' : 'tag-关注';
|
||||
const isConcept = r.target_type === 'concept';
|
||||
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><span class="cat-tag ${isConcept ? '行业' : '公司'}">${isConcept ? '概念/主题' : '股票'}</span></td>
|
||||
<td><b>${r.stock_name}</b>${isConcept ? '' : `<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>
|
||||
@@ -109,10 +111,25 @@ async function showDetail(id) {
|
||||
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>`;
|
||||
};
|
||||
const isConcept = d.target_type === 'concept';
|
||||
let sourceHtml;
|
||||
if (isConcept) {
|
||||
const nc = meta.news_counts || {};
|
||||
sourceHtml = `
|
||||
<details open><summary>🔎 相关资讯(${nc.direct || 0} 条)</summary><div class="src-body"><pre class="src-pre">${escapeHtml(src.db_news || '')}</pre></div></details>
|
||||
<details><summary>🧠 语义检索命中(${nc.rag || 0} 条)</summary><div class="src-body"><pre class="src-pre">${escapeHtml(src.rag_news || '')}</pre></div></details>
|
||||
<details><summary>🏢 受益个股(${nc.related || 0} 只)</summary><div class="src-body"><pre class="src-pre">${escapeHtml(src.related || '')}</pre></div></details>`;
|
||||
} else {
|
||||
sourceHtml = 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>`;
|
||||
}
|
||||
openModal(`
|
||||
<h3>🧭 产业链跟踪:${d.stock_name}(${d.code})</h3>
|
||||
<h3>${isConcept ? '🎯 概念主题跟踪' : '🧭 产业链跟踪'}:${d.stock_name}${d.code && !isConcept ? '(' + d.code + ')' : ''}</h3>
|
||||
<div class="news-meta" style="margin-bottom:12px">
|
||||
<span class="cat-tag">${escapeHtml(d.industry)}</span>
|
||||
<span class="cat-tag ${isConcept ? '行业' : '公司'}">${isConcept ? '概念/主题' : 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>
|
||||
@@ -120,15 +137,11 @@ async function showDetail(id) {
|
||||
<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>
|
||||
${sourceHtml}
|
||||
</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>
|
||||
${isConcept ? '' : `<a class="btn btn-primary" href="/stock/${d.code}" target="_blank">查看个股 →</a>`}
|
||||
</div>
|
||||
`);
|
||||
} catch (e) { toast('加载失败'); }
|
||||
|
||||
@@ -13,6 +13,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt16">
|
||||
<div class="card-title"><span class="bar" style="background:#8b5cf6"></span>📧 定时报告(工作日 9:00 盘前 / 交易日 15:30 盘后)</div>
|
||||
<div class="flex wrap">
|
||||
<button class="btn btn-primary" onclick="sendReport('premarket')">🌅 手动发送盘前分析</button>
|
||||
<button class="btn btn-primary" onclick="sendReport('postmarket')">🌇 手动发送盘后总结</button>
|
||||
</div>
|
||||
<div class="mt16" style="color:var(--muted);font-size:12px;line-height:1.8">
|
||||
自动触发已配置系统 cron:<code>0 9 * * 1-5</code> 盘前 · <code>30 15 * * 1-5</code> 盘后。<br>
|
||||
每期发送两封形态:<b>简单版</b>(邮件正文速览)+ <b>详细版</b>(HTML 附件)。发送记录见下方表格。
|
||||
</div>
|
||||
<div class="card-title mt16" style="font-size:14px"><span class="bar" style="background:#8b5cf6"></span>发送记录</div>
|
||||
<div style="overflow:auto;max-height:300px">
|
||||
<table>
|
||||
<thead><tr><th>时间</th><th>类型</th><th>主题</th><th>状态</th><th>说明</th></tr></thead>
|
||||
<tbody id="reportLogTb"><tr><td colspan="5" class="empty">暂无记录</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt16">
|
||||
<div class="card-title"><span class="bar" style="background:var(--gold)"></span>系统维护</div>
|
||||
<div class="flex wrap">
|
||||
|
||||
@@ -60,16 +60,42 @@
|
||||
</div>
|
||||
<div class="flex mt16 wrap">
|
||||
<button class="btn btn-primary" onclick="saveTracking()">💾 保存跟踪设置</button>
|
||||
<button class="btn" onclick="runTrackAll()">⚡ 立即跟踪全部持仓</button>
|
||||
<button class="btn" onclick="runTrackAll()">⚡ 立即跟踪全部目标</button>
|
||||
<button class="btn" onclick="location.href='/tracking'">🧭 查看全部跟踪报告</button>
|
||||
</div>
|
||||
<div class="mt16 mini-stats" id="trackingState"></div>
|
||||
|
||||
<!-- 持仓(自选股)管理 -->
|
||||
<div class="mt16" style="border-top:1px solid var(--border);padding-top:6px">
|
||||
<div class="card-title mt16" style="font-size:14px"><span class="bar" style="background:var(--gold)"></span>💼 持仓(自选股)管理 —— 跟踪对象 = 持仓</div>
|
||||
<div class="flex mb8 wrap">
|
||||
<input class="input" id="posSearch" placeholder="输入代码 / 名称搜索股票,回车添加" style="flex:1;min-width:200px">
|
||||
<button class="btn btn-primary" onclick="addPos()">+ 添加持仓</button>
|
||||
</div>
|
||||
<div id="posManage" style="overflow:auto;max-height:260px"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 概念/主题目标 -->
|
||||
<div class="mt16" style="border-top:1px solid var(--border);padding-top:6px">
|
||||
<div class="card-title mt16" style="font-size:14px"><span class="bar" style="background:#f472b6"></span>🎯 概念 / 主题 / 股票 跟踪目标</div>
|
||||
<div class="flex mb8 wrap">
|
||||
<select class="input" id="tgtType" style="width:110px">
|
||||
<option value="concept">概念/主题</option>
|
||||
<option value="stock">股票代码</option>
|
||||
</select>
|
||||
<input class="input" id="tgtName" placeholder="目标名称,如:AI算力 / 低空经济(股票类型填代码)" style="flex:1;min-width:200px">
|
||||
<input class="input" id="tgtKeywords" placeholder="检索关键词,逗号分隔(概念填)" style="flex:1;min-width:180px">
|
||||
<button class="btn btn-primary" onclick="addTarget()">+ 添加目标</button>
|
||||
</div>
|
||||
<div id="tgtManage" style="overflow:auto;max-height:260px"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="mt16" style="border-top:1px solid var(--border);padding-top:6px">
|
||||
<div class="card-title mt16" style="font-size:14px"><span class="bar" style="background:#8b5cf6"></span>最近跟踪报告</div>
|
||||
<div style="overflow:auto;max-height:340px">
|
||||
<table>
|
||||
<thead><tr><th>时间</th><th>股票</th><th>影响度</th><th>性质</th><th>摘要</th><th>操作</th></tr></thead>
|
||||
<tbody id="trackTb"><tr><td colspan="6" class="empty">暂无跟踪记录</td></tr></tbody>
|
||||
<thead><tr><th>时间</th><th>类型</th><th>目标</th><th>影响度</th><th>性质</th><th>摘要</th><th>操作</th></tr></thead>
|
||||
<tbody id="trackTb"><tr><td colspan="7" class="empty">暂无跟踪记录</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
<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>
|
||||
🤖 <b style="color:var(--text)">大模型智能体 + 工作流</b>:定期跟踪<b>持仓/自选股</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 btn-primary" onclick="runAll()">⚡ 立即跟踪全部目标</button>
|
||||
<button class="btn" onclick="load()">🔄 刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,8 +27,8 @@
|
||||
<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>
|
||||
<thead><tr><th>时间</th><th>类型</th><th>目标</th><th>行业</th><th>影响度</th><th>性质</th><th>摘要</th><th>操作</th></tr></thead>
|
||||
<tbody id="recTb"><tr><td colspan="8" class="empty">暂无跟踪记录,点击上方「立即跟踪全部持仓」开始</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user