4 Commits
13 changed files with 862 additions and 46 deletions
+44 -12
View File
@@ -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) |
---
@@ -197,30 +200,56 @@ cd works/stock-advisor
**防打扰机制**`monitor_last_news_id` 水位只扫新增;首次启动只建水位不通知历史;单封邮件最多 20 条。
**🌙 静默期**:可配置某些时段不发送邮件(如夜间/午休),支持多段 `23:00-07:30,12:00-13:00`(跨午夜)。命中静默时段时扫描照常、仅抑制发送(日志标记为 `quiet`,水位正常推进,不会在结束后补发积压)。在 ⏰ 自动化页开启。
### 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 过滤)
- **深度分析报告**结构:个股最新动态 / 上游产业链分析 / 下游产业链分析 / 同行业竞争格局 / 产业链传导与投资启示 / 风险提示 / 关注要点
- **个股深度分析**:个股最新动态 / 上游产业链分析 / 下游产业链分析 / 同行业竞争格局 / 产业链传导与投资启示 / 风险提示 / 关注要点
- **概念/主题深度分析**:题材最新动态 / 核心驱动与催化 / 受益标的梳理 / 市场情绪与资金 / 风险提示 / 关注要点
- **影响度判定**:大模型输出 JSONsignificance / 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`
- **🌙 静默期**:数据管理页可配置报告静默时段,命中时段内不生成、不发送(日志标记为 `quiet`
- CLI`python3 reports.py premarket|postmarket`
---
## 🔌 API 文档
@@ -290,7 +319,7 @@ cd works/stock-advisor
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/settings` | 读取全部设置 |
| POST | `/api/settings` | 保存设置 `{mail, llm, monitor}` |
| POST | `/api/settings` | 保存设置 `{mail, llm, monitor, tracking, quiet}`quiet 含 monitor/tracking/report 三任务静默期) |
| POST | `/api/settings/test-email` | 发送测试邮件 |
| POST | `/api/settings/test-llm` | 测试大模型连接 |
| POST | `/api/monitor/scan` | 立即扫描新闻 |
@@ -357,6 +386,9 @@ 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 定时+手动触发 |
| v1.8.0 | 🌙 静默期:舆情监控/持仓跟踪/定时报告三大自动化任务均可独立配置静默时段(多段/跨午夜),命中时段不发送邮件(日志标记 quiet) |
---
+40 -1
View File
@@ -561,7 +561,7 @@ def api_backtest_rebuild():
@app.route("/api/settings")
def api_settings():
from settings import all_settings, mail_config, monitor_config, monitor_state, \
tracking_config, tracking_state
tracking_config, tracking_state, quiet_config
from config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL
s = all_settings()
mail = mail_config()
@@ -571,6 +571,11 @@ def api_settings():
"mail": mail,
"monitor": mono,
"tracking": {**track, **tracking_state()},
"quiet": { # 各自动化任务的静默期配置
"monitor": quiet_config("monitor"),
"tracking": quiet_config("tracking"),
"report": quiet_config("report"),
},
"llm": {
"base_url": s.get("llm_base_url", LLM_BASE_URL),
"api_key": s.get("llm_api_key", LLM_API_KEY),
@@ -614,6 +619,14 @@ def api_settings_save():
set_setting("tracking_enabled", _bool_str(track["tracking_enabled"]))
if "tracking_notify" in track:
set_setting("tracking_notify", _bool_str(track["tracking_notify"]))
# 静默期(各自动化任务独立配置)
quiet = body.get("quiet") or {}
for prefix in ("monitor", "tracking", "report"):
q = quiet.get(prefix) or {}
if "enabled" in q:
set_setting(f"{prefix}_quiet_enabled", _bool_str(q["enabled"]))
if "ranges" in q:
set_setting(f"{prefix}_quiet_ranges", str(q["ranges"] or "").strip())
return jsonify({"ok": True, "msg": "设置已保存"})
@@ -741,6 +754,32 @@ def api_targets_delete(tid):
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():
+17 -1
View File
@@ -164,12 +164,28 @@ CREATE TABLE IF NOT EXISTS watch_targets (
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}}
);
"""
@@ -235,7 +251,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", "tracking_reports",
"watch_targets"):
"watch_targets", "global_markets", "report_log"):
with db() as conn:
conn.execute(f'DELETE FROM "{t}"')
with db() as conn:
+7 -2
View File
@@ -19,7 +19,8 @@ import threading
import time
from database import query, query_one, execute
from settings import tracking_config, tracking_state, set_tracking_state, mail_config
from settings import tracking_config, tracking_state, set_tracking_state, mail_config, \
quiet_config, in_quiet_period
from engine.chain_data import get_chain
from engine.indicators import compute_indicators
from rag.vector_store import query_vectors
@@ -287,10 +288,14 @@ def _first_heading(text):
def _notify_if_significant(rid, stock, meta):
"""影响度达阈值且开启通知 → 邮件(股票/概念通用)"""
"""影响度达阈值且开启通知 → 邮件(股票/概念通用)。静默期内抑制发送。"""
cfg = tracking_config()
try:
if cfg["notify"] and int(meta.get("impact_score", 0)) >= int(cfg["impact_threshold"]):
qc = quiet_config("tracking")
if qc["enabled"] and in_quiet_period(qc["ranges"]):
log.info("tracking notify suppressed by quiet period: %s", stock.get("name"))
return
from engine.notifier import send_email
mc = mail_config()
nc = meta.get("news_counts") or {}
+47 -25
View File
@@ -18,7 +18,8 @@ from email.mime.text import MIMEText
from email.utils import formataddr, formatdate
from database import query, execute, query_one
from settings import mail_config, monitor_config, monitor_state, set_monitor_state
from settings import mail_config, monitor_config, monitor_state, set_monitor_state, \
quiet_config, in_quiet_period
log = logging.getLogger("notifier")
@@ -30,11 +31,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
@@ -166,27 +181,34 @@ def scan_news(force=False):
sent, failed = 0, 0
if important:
# 单封邮件最多带 20 条,避免超大邮件
shown = important[:20]
omitted = len(important) - len(shown)
items = [{"news": it["news"], "score": it["score"],
"stocks": _fetch_related_names([c for c in (it["news"]["related_stocks"] or "").split(",") if c])}
for it in shown]
subject = f"[舆情监控] {len(important)} 条重要资讯:{important[0]['news']['title'][:24]}" + \
(f"{len(important)}" if len(important) > 1 else "")
try:
html = build_email_html(items)
if omitted:
html += f"<p style='color:#888;font-size:12px'>……另有 {omitted} 条重要资讯已省略,详见系统。</p>"
if cfg.get("email_enabled", True):
send_email(subject, html)
status, msg = "sent", ""
else:
status, msg = "skipped", "邮件功能未启用"
except Exception as e:
status, msg = "failed", str(e)
failed = len(important)
log.warning("notify email fail: %s", e)
# 静默期判断:命中则本次不发送邮件(仍记账推进水位,避免静默结束后补发积压)
qc = quiet_config("monitor")
quiet = qc["enabled"] and in_quiet_period(qc["ranges"])
omitted = 0
if quiet:
status, msg = "quiet", "静默期内已抑制发送"
else:
# 单封邮件最多带 20 条,避免超大邮件
shown = important[:20]
omitted = len(important) - len(shown)
items = [{"news": it["news"], "score": it["score"],
"stocks": _fetch_related_names([c for c in (it["news"]["related_stocks"] or "").split(",") if c])}
for it in shown]
subject = f"[舆情监控] {len(important)} 条重要资讯:{important[0]['news']['title'][:24]}" + \
(f"{len(important)}" if len(important) > 1 else "")
try:
html = build_email_html(items)
if omitted:
html += f"<p style='color:#888;font-size:12px'>……另有 {omitted} 条重要资讯已省略,详见系统。</p>"
if cfg.get("email_enabled", True):
send_email(subject, html)
status, msg = "sent", ""
else:
status, msg = "skipped", "邮件功能未启用"
except Exception as e:
status, msg = "failed", str(e)
failed = len(important)
log.warning("notify email fail: %s", e)
# 写日志
for it in important:
execute(
+471
View File
@@ -0,0 +1,471 @@
# -*- 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, quiet_config, in_quiet_period
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
# 静默期判断:命中则不生成、不发送,仅记一条 quiet 日志
qc = quiet_config("report")
if qc["enabled"] and in_quiet_period(qc["ranges"]):
meta = KIND_META.get(kind, KIND_META["premarket"])
subject = f"[智能荐股] {meta['name']} {time.strftime('%Y-%m-%d')}"
execute("INSERT INTO report_log(kind, subject, brief_len, detail_len, status, message) "
"VALUES(?,?,0,0,'quiet',?)", (kind, subject, "静默期内已抑制发送"))
return {"ok": False, "quiet": True, "msg": "静默期内已抑制发送"}
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
View File
@@ -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
View File
@@ -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
+48
View File
@@ -4,6 +4,8 @@
- get_setting(key, default) / set_setting(key, value) / all_settings() / save_all(dict)
- 提供 LLM / 邮件 / 监控 三段配置的便捷读取
"""
import time
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, TRACKING_DEFAULTS
@@ -116,6 +118,52 @@ def tracking_config():
}
# ===================================================================== 静默期
def quiet_config(prefix):
"""某个自动化任务的静默期配置。prefix: monitor / tracking / report"""
return {
"enabled": _truthy(get_setting(f"{prefix}_quiet_enabled", "0")),
"ranges": (get_setting(f"{prefix}_quiet_ranges", "") or "").strip(),
}
def in_quiet_period(ranges_str=None, now=None):
"""判断当前时刻是否落在静默时段内。
ranges 格式:"22:00-07:30"(支持跨午夜)或 "23:00-07:30,12:00-13:00"(多个时段逗号分隔)。
任一时段命中即返回 True;解析失败/空配置返回 False。
"""
ranges_str = (ranges_str or "").strip()
if not ranges_str:
return False
if now is None:
now = time.localtime()
if hasattr(now, "tm_hour"):
cur = now.tm_hour * 60 + now.tm_min
else: # datetime 对象
cur = now.hour * 60 + now.minute
for part in ranges_str.replace("", ",").split(","):
part = part.strip()
if "-" not in part:
continue
try:
a, b = part.split("-", 1)
sh, sm = a.strip().split(":")
eh, em = b.strip().split(":")
start = int(sh) * 60 + int(sm)
end = int(eh) * 60 + int(em)
except Exception:
continue
if start == end:
continue
if start < end:
if start <= cur < end:
return True
else: # 跨天时段,如 23:00-07:30
if cur >= start or cur < end:
return True
return False
def tracking_state():
return {
"last_run": get_setting("tracking_last_run", ""),
+50
View File
@@ -40,6 +40,54 @@ 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 loadReportQuiet() {
try {
const d = await api('/api/settings');
const q = (d.quiet && d.quiet.report) || {};
$('#reportQuietEnabled').checked = !!q.enabled;
$('#reportQuietRanges').value = q.ranges || '';
} catch (e) {}
}
async function saveReportQuiet() {
try {
const r = await api('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quiet: { report: {
enabled: $('#reportQuietEnabled').checked,
ranges: $('#reportQuietRanges').value.trim()
} } }) });
toast(r.msg || '已保存');
} catch (e) { toast('保存失败:' + e.message); }
}
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-推荐' : r.status === 'quiet' ? 'tag-关注' : 'tag-负'}">${r.status === 'sent' ? '已发送' : r.status === 'quiet' ? '🌙 静默' : '失败'}</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 +115,5 @@ async function health() {
}
refreshStats();
loadReportLog();
loadReportQuiet();
+28 -3
View File
@@ -21,12 +21,20 @@ async function loadAuto() {
const cats = mono.categories || [];
$$('#monitorCats input').forEach(c => c.checked = cats.includes(c.value));
renderMonitorState(autoSettings.state);
// 静默期(舆情)
const mq = (autoSettings.quiet && autoSettings.quiet.monitor) || {};
$('#monitorQuietEnabled').checked = !!mq.enabled;
$('#monitorQuietRanges').value = mq.ranges || '';
// 持仓跟踪
$('#trackingEnabled').checked = !!tr.enabled;
$('#trackingInterval').value = tr.interval_min;
$('#trackingImpact').value = tr.impact_threshold;
$('#trackingNotify').checked = !!tr.notify;
renderTrackingState(tr);
// 静默期(跟踪)
const tq = (autoSettings.quiet && autoSettings.quiet.tracking) || {};
$('#trackingQuietEnabled').checked = !!tq.enabled;
$('#trackingQuietRanges').value = tq.ranges || '';
loadLog();
loadTrackReports();
loadPositions();
@@ -61,10 +69,27 @@ function collectMonitor() {
};
}
function collectMonitorQuiet() {
return {
enabled: $('#monitorQuietEnabled').checked,
ranges: $('#monitorQuietRanges').value.trim()
};
}
function collectTrackingQuiet() {
return {
enabled: $('#trackingQuietEnabled').checked,
ranges: $('#trackingQuietRanges').value.trim()
};
}
/* ===================== 保存 ===================== */
async function saveMonitor() {
try {
const r = await api('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ monitor: collectMonitor() }) });
const r = await api('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
monitor: collectMonitor(),
quiet: { monitor: collectMonitorQuiet() }
}) });
toast(r.msg || '已保存');
} catch (e) { toast('保存失败:' + e.message); }
}
@@ -76,7 +101,7 @@ async function saveTracking() {
tracking_interval: $('#trackingInterval').value,
tracking_impact_threshold: $('#trackingImpact').value,
tracking_notify: $('#trackingNotify').checked
} }) });
}, quiet: { tracking: collectTrackingQuiet() } }) });
toast(r.msg || '已保存');
} catch (e) { toast('保存失败:' + e.message); }
}
@@ -206,7 +231,7 @@ async function loadLog() {
<td><span class="cat-tag ${n.category}">${n.category}</span></td>
<td class="num ${pctClass(n.sentiment)}">${n.sentiment > 0 ? '利好' : n.sentiment < 0 ? '利空' : '中性'} (${Number(n.sentiment).toFixed(2)})</td>
<td class="num"><b>${n.importance}</b></td>
<td><span class="tag ${n.status === 'sent' ? 'tag-推荐' : n.status === 'skipped' ? 'tag-关注' : 'tag-负'}">${n.status}</span></td>
<td><span class="tag ${n.status === 'sent' ? 'tag-推荐' : n.status === 'skipped' ? 'tag-关注' : n.status === 'quiet' ? 'tag-关注' : 'tag-负'}">${n.status === 'sent' ? '已发送' : n.status === 'skipped' ? '已跳过' : n.status === 'quiet' ? '🌙 静默' : '失败'}</span></td>
</tr>`).join('');
} catch (e) {}
}
+30
View File
@@ -13,6 +13,36 @@
</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="mt16" style="border-top:1px solid var(--border);padding-top:10px">
<div class="between mt16">
<div class="card-title" style="font-size:14px;margin-bottom:0"><span class="bar" style="background:#f472b6"></span>🌙 静默期 —— 某些时段不发送报告邮件</div>
<label class="switch"><input type="checkbox" id="reportQuietEnabled"><span></span></label>
</div>
<div class="mt8 settings-form">
<div class="sf-row" style="align-items:flex-start"><label>静默时段</label><input class="input" id="reportQuietRanges" placeholder="如 09:30-10:00,11:30-13:00(多段逗号分隔,支持跨午夜)" style="width:100%"></div>
</div>
<div class="mt8"><button class="btn btn-primary" onclick="saveReportQuiet()">💾 保存报告静默期</button></div>
<div style="color:var(--muted);font-size:12px;line-height:1.8">开启后,落在静默时段内的定时/手动报告将<span style="color:var(--gold)">不生成、不发送</span>(发送记录标记为“静默”)。</div>
</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">
+20
View File
@@ -29,6 +29,16 @@
</div>
<div class="sf-row" style="align-items:flex-start"><label>关键词(逗号分隔)</label><textarea class="input" id="monitorKeywords" rows="2" style="width:100%"></textarea></div>
</div>
<div class="mt16" style="border-top:1px solid var(--border);padding-top:6px">
<div class="between mt16">
<div class="card-title" style="font-size:14px;margin-bottom:0"><span class="bar" style="background:#f472b6"></span>🌙 静默期 —— 某些时段不发送邮件通知</div>
<label class="switch"><input type="checkbox" id="monitorQuietEnabled"><span></span></label>
</div>
<div class="mt8 settings-form">
<div class="sf-row" style="align-items:flex-start"><label>静默时段</label><input class="input" id="monitorQuietRanges" placeholder="如 22:00-07:30(跨午夜)或 23:00-07:30,12:00-13:00(多段逗号分隔)" style="width:100%"></div>
</div>
<div style="color:var(--muted);font-size:12px;line-height:1.8">开启后,落在静默时段内的扫描将<span style="color:var(--gold)">不发送邮件</span>(通知日志标记为“静默”,水位正常推进,不会在结束后补发积压)。</div>
</div>
<div class="flex mt16 wrap">
<button class="btn btn-primary" onclick="saveMonitor()">💾 保存监控设置</button>
<button class="btn" onclick="scanNow()">⚡ 立即扫描</button>
@@ -58,6 +68,16 @@
<label class="switch"><input type="checkbox" id="trackingNotify"><span></span></label>
</div>
</div>
<div class="mt16" style="border-top:1px solid var(--border);padding-top:6px">
<div class="between mt16">
<div class="card-title" style="font-size:14px;margin-bottom:0"><span class="bar" style="background:#f472b6"></span>🌙 静默期 —— 某些时段不发送邮件通知</div>
<label class="switch"><input type="checkbox" id="trackingQuietEnabled"><span></span></label>
</div>
<div class="mt8 settings-form">
<div class="sf-row" style="align-items:flex-start"><label>静默时段</label><input class="input" id="trackingQuietRanges" placeholder="如 22:00-07:30(跨午夜)或 23:00-07:30,12:00-13:00(多段逗号分隔)" style="width:100%"></div>
</div>
<div style="color:var(--muted);font-size:12px;line-height:1.8">开启后,落在静默时段内发现的重大变化将<span style="color:var(--gold)">不发送邮件</span>(跟踪报告照常生成入库)。</div>
</div>
<div class="flex mt16 wrap">
<button class="btn btn-primary" onclick="saveTracking()">💾 保存跟踪设置</button>
<button class="btn" onclick="runTrackAll()">⚡ 立即跟踪全部目标</button>