From 9340577f7b17d0dd22e01b5234316c8434f32b9f Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Thu, 20 Aug 2026 23:43:02 +0800 Subject: [PATCH] =?UTF-8?q?v1.8.0:=20=F0=9F=8C=99=20=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E6=9C=9F=20-=20=E8=88=86=E6=83=85=E7=9B=91=E6=8E=A7/=E6=8C=81?= =?UTF-8?q?=E4=BB=93=E8=B7=9F=E8=B8=AA/=E5=AE=9A=E6=97=B6=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E4=B8=89=E5=A4=A7=E8=87=AA=E5=8A=A8=E5=8C=96=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=8B=AC=E7=AB=8B=E9=85=8D=E7=BD=AE=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E6=97=B6=E6=AE=B5,=20=E5=91=BD=E4=B8=AD=E6=97=B6=E6=AE=B5?= =?UTF-8?q?=E4=B8=8D=E5=8F=91=E9=80=81=E9=82=AE=E4=BB=B6(=E5=A4=9A?= =?UTF-8?q?=E6=AE=B5/=E8=B7=A8=E5=8D=88=E5=A4=9C,=20=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=A0=87quiet,=20=E6=B0=B4=E4=BD=8D=E7=85=A7=E5=B8=B8=E6=8E=A8?= =?UTF-8?q?=E8=BF=9B=E4=B8=8D=E8=A1=A5=E5=8F=91=E7=A7=AF=E5=8E=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 +++++- app.py | 15 ++++++++++- engine/agent.py | 9 +++++-- engine/notifier.py | 52 ++++++++++++++++++++++----------------- engine/report.py | 12 +++++++-- settings.py | 48 ++++++++++++++++++++++++++++++++++++ static/js/admin.js | 23 ++++++++++++++++- static/js/automation.js | 31 ++++++++++++++++++++--- templates/admin.html | 11 +++++++++ templates/automation.html | 20 +++++++++++++++ 10 files changed, 196 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 6170397..9530e91 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,8 @@ 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) 对**持仓(自选股)**与**目标概念/主题/股票**定期主动跟踪,由**大模型智能体 + 工作流**驱动: @@ -219,6 +221,7 @@ cd works/stock-advisor - **影响度判定**:大模型输出 JSON(significance / impact_score / change_kind / summary / chain_trend),前置输出防截断,容错解析 - **主动调度**:后台调度器按间隔(默认 60 分钟)跟踪全部持仓+目标;支持「立即跟踪全部目标/单只」 - **重大变化通知**:impact_score ≥ 阈值(默认 65)自动邮件(股票/概念通用) +- **🌙 静默期**:可配置某些时段不发送重大变化邮件(跟踪报告照常生成入库),在 ⏰ 自动化页开启,多段/跨午夜均支持 - **页面 `/tracking`**:股票+概念报告总览;**⏰ 自动化**页可管理持仓与目标、查看最近报告 ### 6. 系统设置(/settings) @@ -244,6 +247,7 @@ cd works/stock-advisor - **全球市场数据** `global_markets` 表(模拟):道琼斯/纳斯达克/标普/恒生/日经/KOSPI/德法英等 9 大指数 - 报告由 DeepSeek 基于当日数据生成研判(简版+详版两次调用),失败自动降级为数据速览 - **手动触发**:数据管理页「定时报告」卡片(🌅/🌇 按钮)+ 发送日志 `report_log` 表 +- **🌙 静默期**:数据管理页可配置报告静默时段,命中时段内不生成、不发送(日志标记为 `quiet`) - CLI:`python3 reports.py premarket|postmarket` --- @@ -315,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` | 立即扫描新闻 | @@ -384,6 +388,7 @@ IS_MOCK = False | v1.5.0 | 舆情监控与持仓跟踪合并为「⏰ 自动化」导航入口(双选项卡);修复布尔设置存储兼容 | | v1.6.0 | 目标跟踪:概念/主题/股票(watch_targets),概念智能体分析+受益个股,持仓管理与目标管理,报告支持双类型 | | v1.7.0 | 每日定时报告:工作日 9:00 盘前 / 15:30 盘后,简版正文+详细版附件,全球市场数据,cron 定时+手动触发 | +| v1.8.0 | 🌙 静默期:舆情监控/持仓跟踪/定时报告三大自动化任务均可独立配置静默时段(多段/跨午夜),命中时段不发送邮件(日志标记 quiet) | --- diff --git a/app.py b/app.py index ddbe662..1ef8564 100644 --- a/app.py +++ b/app.py @@ -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": "设置已保存"}) diff --git a/engine/agent.py b/engine/agent.py index 9ec5a59..fddc6f3 100644 --- a/engine/agent.py +++ b/engine/agent.py @@ -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 {} diff --git a/engine/notifier.py b/engine/notifier.py index 3af96c7..948cae0 100644 --- a/engine/notifier.py +++ b/engine/notifier.py @@ -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") @@ -180,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"

……另有 {omitted} 条重要资讯已省略,详见系统。

" - 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"

……另有 {omitted} 条重要资讯已省略,详见系统。

" + 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( diff --git a/engine/report.py b/engine/report.py index aabbf45..ec28eec 100644 --- a/engine/report.py +++ b/engine/report.py @@ -14,7 +14,7 @@ import logging import time from database import query, query_one, execute -from settings import mail_config +from settings import mail_config, quiet_config, in_quiet_period from engine.analyst import llm_chat log = logging.getLogger("report") @@ -428,8 +428,16 @@ th{{background:#f8fafc;color:#475569;}} # ===================================================================== 发送 def send_daily_report(kind="premarket"): - """生成并发送报告:正文=简版,附件=详细版 HTML。返回 dict 状态""" + """生成并发送报告:正文=简版,附件=详细版 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"]) diff --git a/settings.py b/settings.py index d1d300e..edc93a9 100644 --- a/settings.py +++ b/settings.py @@ -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", ""), diff --git a/static/js/admin.js b/static/js/admin.js index be0d4b8..81c3fe1 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -52,6 +52,26 @@ async function sendReport(kind) { 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'); @@ -62,7 +82,7 @@ async function loadReportLog() { ${r.sent_at} ${r.kind === 'premarket' ? '🌅 盘前' : '🌇 盘后'} ${escapeHtml(r.subject)} - ${r.status} + ${r.status === 'sent' ? '已发送' : r.status === 'quiet' ? '🌙 静默' : '失败'} ${escapeHtml(r.message || '')} `).join(''); } catch (e) {} @@ -96,3 +116,4 @@ async function health() { refreshStats(); loadReportLog(); +loadReportQuiet(); diff --git a/static/js/automation.js b/static/js/automation.js index 0d0ef69..96c2e39 100644 --- a/static/js/automation.js +++ b/static/js/automation.js @@ -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() { ${n.category} ${n.sentiment > 0 ? '利好' : n.sentiment < 0 ? '利空' : '中性'} (${Number(n.sentiment).toFixed(2)}) ${n.importance} - ${n.status} + ${n.status === 'sent' ? '已发送' : n.status === 'skipped' ? '已跳过' : n.status === 'quiet' ? '🌙 静默' : '失败'} `).join(''); } catch (e) {} } diff --git a/templates/admin.html b/templates/admin.html index 93cbfe3..9ded37a 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -23,6 +23,17 @@ 自动触发已配置系统 cron:0 9 * * 1-5 盘前 · 30 15 * * 1-5 盘后。
每期发送两封形态:简单版(邮件正文速览)+ 详细版(HTML 附件)。发送记录见下方表格。 +
+
+
🌙 静默期 —— 某些时段不发送报告邮件
+ +
+
+
+
+
+
开启后,落在静默时段内的定时/手动报告将不生成、不发送(发送记录标记为“静默”)。
+
发送记录
diff --git a/templates/automation.html b/templates/automation.html index 8cd5c5e..fd9432a 100644 --- a/templates/automation.html +++ b/templates/automation.html @@ -29,6 +29,16 @@
+
+
+
🌙 静默期 —— 某些时段不发送邮件通知
+ +
+
+
+
+
开启后,落在静默时段内的扫描将不发送邮件(通知日志标记为“静默”,水位正常推进,不会在结束后补发积压)。
+
@@ -58,6 +68,16 @@
+
+
+
🌙 静默期 —— 某些时段不发送邮件通知
+ +
+
+
+
+
开启后,落在静默时段内发现的重大变化将不发送邮件(跟踪报告照常生成入库)。
+