3 Commits
11 changed files with 532 additions and 63 deletions
+30 -4
View File
@@ -24,7 +24,7 @@
| 📑 AI 分析详情 | `/analysis/<id>` | 研报正文 + **大模型参考的数据源**(RAG新闻/概况/指标/评级/持仓/提示词) |
| 🧭 持仓跟踪报告 | `/tracking` | 产业链深度跟踪报告总览(⏰ 自动化页入口进入) |
| 🔧 系统设置 | `/settings` | 邮件 SMTP + 大模型接口配置 |
| ⚙️ 数据管理 | `/admin` | 数据统计、向量库状态、一键重灌、重建回测、依赖体检 |
| ⚙️ 数据管理 | `/admin` | 数据统计、向量库状态、一键重灌、重建回测、**定时报告手动触发与日志** |
---
@@ -132,6 +132,8 @@ cd works/stock-advisor
| `notification_log` | 舆情通知日志 | news_id/importance/status |
| `watch_targets` | 跟踪目标(概念/主题/股票) | type/name/keywords/enabled |
| `tracking_reports` | 跟踪报告(含类型/数据源/影响度 JSON | target_type/code/report/meta |
| `report_log` | 定时报告发送日志 | kind/subject/status |
| `global_markets` | 全球主要指数(模拟) | date/data(JSON) |
---
@@ -198,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
对**持仓(自选股)**与**目标概念/主题/股票**定期主动跟踪,由**大模型智能体 + 工作流**驱动:
@@ -217,16 +221,35 @@ cd works/stock-advisor
- **影响度判定**:大模型输出 JSONsignificance / impact_score / change_kind / summary / chain_trend),前置输出防截断,容错解析
- **主动调度**:后台调度器按间隔(默认 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 文档
@@ -296,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` | 立即扫描新闻 |
@@ -364,6 +387,9 @@ IS_MOCK = False
| v1.4.0 | 持仓跟踪智能体:产业链上下游深度分析(上游供给/成本、下游需求/景气、同业),影响度判定+重大变化通知 |
| v1.5.0 | 舆情监控与持仓跟踪合并为「⏰ 自动化」导航入口(双选项卡);修复布尔设置存储兼容 |
| v1.6.0 | 目标跟踪:概念/主题/股票(watch_targets),概念智能体分析+受益个股,持仓管理与目标管理,报告支持双类型 |
| v1.7.0 | 每日定时报告:工作日 9:00 盘前 / 15:30 盘后,简版正文+详细版附件,全球市场数据,cron 定时+手动触发 |
| v1.8.0 | 🌙 静默期:舆情监控/持仓跟踪/定时报告三大自动化任务均可独立配置静默时段(多段/跨午夜),命中时段不发送邮件(日志标记 quiet) |
| v1.9.0 | ①自动化页开关/参数全部**即时保存**(切换即生效,无需再点保存按钮,刷新不回弹);②持仓跟踪新增**重大变化推送方式**:聚合一起发(默认,一轮全部分析完统一发一封汇总邮件)/ 分散发(每只出结果立即单独发);③**行情数据自动延续**:新增 extend_daily 增量扩展(行情/新闻/指数从库内最新日期延续到最新交易日,幂等),服务每小时自动检查+数据管理页「📅 更新行情到最新交易日」手动触发,数据不再停留在建库日 |
---
+68 -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,16 @@ 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"]))
if "tracking_push_mode" in track:
set_setting("tracking_push_mode", str(track["tracking_push_mode"]).strip().lower())
# 静默期(各自动化任务独立配置)
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": "设置已保存"})
@@ -773,6 +788,8 @@ def api_admin_stats():
tables = ("stocks", "stock_daily", "news", "institutions", "inst_ratings",
"fund_holdings", "watchlist", "analysis_cache", "analysis_history",
"strategy_backtests", "market_index")
latest_daily = query_one("SELECT MAX(date) d FROM stock_daily")
latest_news = query_one("SELECT MAX(publish_date) d FROM news")
return jsonify({
"tables": {t: table_count(t) for t in tables},
"vector": {
@@ -781,6 +798,8 @@ def api_admin_stats():
},
"is_mock": IS_MOCK,
"db": "stock_advisor.db",
"latest_daily": (latest_daily or {}).get("d"),
"latest_news": (latest_news or {}).get("d"),
})
@@ -801,6 +820,29 @@ def api_admin_reseed():
return jsonify({"ok": True, "msg": "重灌任务已启动,可在数据管理页刷新查看进度"})
@app.route("/api/admin/update-data", methods=["POST"])
def api_admin_update_data():
"""把行情/新闻/指数增量扩展到最新交易日(模拟数据延续),后台执行"""
import threading
def run():
try:
from seed_data import extend_daily
with open(os.path.join(LOG_DIR, "data_update.log"), "a") as f:
f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 开始更新数据\n")
res = extend_daily()
with open(os.path.join(LOG_DIR, "data_update.log"), "a") as f:
f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 完成: {res}\n")
log.info("data extend done: %s", res)
except Exception as e:
log.exception("data extend fail")
with open(os.path.join(LOG_DIR, "data_update.log"), "a") as f:
f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 失败: {e}\n")
threading.Thread(target=run, daemon=True).start()
return jsonify({"ok": True, "msg": "行情更新已启动(增量扩展到最新交易日),稍后刷新数据管理页查看"})
@app.route("/api/admin/healthcheck")
def api_admin_healthcheck():
"""外部依赖连通性检查"""
@@ -822,10 +864,35 @@ def api_admin_healthcheck():
if __name__ == "__main__":
import threading
init_db()
from engine.notifier import start_monitor
from engine.agent import start_tracking
start_monitor()
start_tracking()
# 行情自动更新器:每小时检查,若行情最新日期落后于最新交易日则增量扩展(模拟数据延续)
class DataUpdater(threading.Thread):
def __init__(self):
super().__init__(daemon=True, name="data-updater")
self._stop = threading.Event()
def run(self):
log.info("行情自动更新器启动(每小时检查)")
while not self._stop.is_set():
try:
import datetime as _dt
last = query_one("SELECT MAX(date) d FROM stock_daily")
today = _dt.date.today()
while today.weekday() >= 5:
today -= _dt.timedelta(days=1)
if not last or last["d"] < today.isoformat():
from seed_data import extend_daily
res = extend_daily()
log.info("行情自动扩展: %s", res)
except Exception as e:
log.warning("data updater error: %s", e)
self._stop.wait(3600)
DataUpdater().start()
print(f"{SERVICE_NAME} 启动: http://0.0.0.0:{SERVICE_PORT}")
app.run(host=SERVICE_HOST, port=SERVICE_PORT, threaded=True)
+98 -13
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
@@ -221,8 +222,8 @@ def strip_json_block(text):
# ===================================================================== 执行
def track_stock(code, focus=""):
"""执行一次跟踪,返回 {ok, report_id, meta, ...}"""
def track_stock(code, focus="", notify=True):
"""执行一次跟踪,返回 {ok, report_id, meta, ...}。notify=False 时不单独发邮件(聚合推送模式)"""
with _track_lock:
seg = collect_chain(code)
if not seg:
@@ -266,7 +267,8 @@ def track_stock(code, focus=""):
(code, s["name"], s["industry"], clean_report, json.dumps(meta, ensure_ascii=False),
json.dumps(sources, ensure_ascii=False)))
rid = query_one("SELECT MAX(id) id FROM tracking_reports")["id"]
_notify_if_significant(rid, s, meta)
if notify:
_notify_if_significant(rid, s, meta)
return {"ok": True, "report_id": rid, "meta": meta}
except Exception as e:
log.exception("track %s fail", code)
@@ -287,10 +289,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 {}
@@ -318,8 +324,9 @@ def _notify_if_significant(rid, stock, meta):
# ===================================================================== 概念/主题跟踪
def track_concept(name, keywords="", focus=""):
"""跟踪一个概念/主题:采集相关新闻 → 受益个股梳理 → LLM 深度分析 → 影响度判定"""
def track_concept(name, keywords="", focus="", notify=True):
"""跟踪一个概念/主题:采集相关新闻 → 受益个股梳理 → LLM 深度分析 → 影响度判定
notify=False 时不单独发邮件(聚合推送模式)"""
kw = keywords.strip() or name
kw_list = [k.strip() for k in kw.replace("", ",").split(",") if k.strip()]
kws = " ".join(kw_list)
@@ -406,7 +413,8 @@ def track_concept(name, keywords="", focus=""):
(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)
if notify:
_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)
@@ -432,9 +440,69 @@ def delete_target(tid):
return {"ok": True}
def _notify_aggregate(items):
"""聚合推送:一轮跟踪全部完成后,统一发一封汇总邮件(默认推送方式)"""
if not items:
return
cfg = tracking_config()
try:
if not cfg["notify"]:
return
qc = quiet_config("tracking")
if qc["enabled"] and in_quiet_period(qc["ranges"]):
log.info("tracking aggregate notify suppressed by quiet period (%d items)", len(items))
return
from engine.notifier import send_email
mc = mail_config()
rows = ""
for it in items:
meta = it.get("meta") or {}
is_concept = it.get("type") == "concept"
impact = int(meta.get("impact_score", 0))
level = "🔴 重大" if impact >= 65 else "🟡 关注"
name = escape_html(it.get("name", ""))
code = it.get("code", "")
rows += (
f"<tr>"
f"<td style='padding:8px;border:1px solid #e5e7eb'>{'🎯 概念' if is_concept else '💼 股票'}</td>"
f"<td style='padding:8px;border:1px solid #e5e7eb'><b>{name}</b>"
f"{'<div style=color:#888;font-size:12px>' + str(code) + '</div>' if not is_concept and code else ''}</td>"
f"<td style='padding:8px;border:1px solid #e5e7eb'>{meta.get('change_kind', '')}</td>"
f"<td style='padding:8px;border:1px solid #e5e7eb'>{impact}/100 <span style='color:#888;font-size:12px'>({level})</span></td>"
f"<td style='padding:8px;border:1px solid #e5e7eb;color:#555'>{escape_html(meta.get('summary', ''))}</td>"
f"</tr>"
)
send_email(
f"[持仓跟踪] 聚合报告:{len(items)} 个目标出现重大动态(影响度≥{int(cfg['impact_threshold'])}",
f"""<html><body style="font-family:Microsoft YaHei;padding:20px;background:#f5f6f8;">
<div style="max-width:760px;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;">🧭 持仓跟踪聚合报告 · {time.strftime('%Y-%m-%d %H:%M')}</div>
<div style="padding:16px 20px;">
<p style="color:#555;">本轮共跟踪 {items[0].get('_total', len(items))} 个目标,其中 <b>{len(items)}</b> 个达到重大变化判定阈值(影响度 ≥ {int(cfg['impact_threshold'])}):</p>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tr style="background:#f1f5f9"><th style="padding:8px;border:1px solid #e5e7eb;text-align:left">类型</th><th style="padding:8px;border:1px solid #e5e7eb;text-align:left">目标</th><th style="padding:8px;border:1px solid #e5e7eb;text-align:left">性质</th><th style="padding:8px;border:1px solid #e5e7eb;text-align:left">影响度</th><th style="padding:8px;border:1px solid #e5e7eb;text-align:left">摘要</th></tr>
{rows}
</table>
<p style="color:#888;font-size:12px;margin-top:14px;">详细分析报告请在系统「🧭 持仓跟踪智能体 → 最近跟踪报告」中查看。</p>
</div></div></body></html>""",
cfg=mc)
set_tracking_state(last_alert=int(items[0]["meta"].get("impact_score", 0)))
log.info("tracking aggregate notify sent: %d items", len(items))
except Exception as e:
log.warning("tracking aggregate notify fail: %s", e)
def escape_html(s):
return str(s or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
# ===================================================================== 批量与调度
def track_all(progress=None):
"""跟踪全部目标:持仓(自选股)+ 目标(概念/主题/股票)"""
"""跟踪全部目标:持仓(自选股)+ 目标(概念/主题/股票)
推送方式(tracking_push_mode):
aggregate(默认) —— 一轮全部分析完后,汇总重大变化统一发一封邮件;
scattered —— 每只股票分析完立即单独发邮件。
"""
global _cycle_running
if _cycle_running:
return {"tracked": 0, "msg": "已有跟踪任务进行中,请稍后再试"}
@@ -460,16 +528,33 @@ def track_all(progress=None):
uniq.append((typ, key, name))
if not uniq:
return {"tracked": 0, "msg": "暂无跟踪目标:请添加持仓/自选股或概念主题目标"}
cfg = tracking_config()
scattered = cfg.get("push_mode", "aggregate") == "scattered"
significant = [] # 聚合推送模式收集重大变化
results = []
for i, (typ, key, name) in enumerate(uniq):
if typ == "stock":
r = track_stock(key)
else:
r = track_concept(key, name)
try:
if typ == "stock":
r = track_stock(key, notify=scattered)
else:
r = track_concept(key, name, notify=scattered)
except Exception as e:
r = {"error": str(e)}
results.append({"type": typ, "name": name, **r})
if r.get("ok"):
meta = r.get("meta") or {}
if int(meta.get("impact_score", 0)) >= int(cfg["impact_threshold"]):
significant.append({"type": typ, "name": name, "code": key,
"meta": meta, "_total": len(uniq)})
set_tracking_state(last_run=time.strftime("%Y-%m-%d %H:%M:%S"), last_stock=name)
if progress:
progress(i + 1, len(uniq))
# 聚合推送:一轮分析完统一发一封汇总邮件
if not scattered and significant:
try:
_notify_aggregate(significant)
except Exception as e:
log.warning("aggregate notify error: %s", e)
return {"tracked": len(results), "results": results}
finally:
_cycle_running = False
+30 -22
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")
@@ -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"<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(
+10 -2
View File
@@ -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"])
+118 -1
View File
@@ -21,7 +21,7 @@ import random
import sys
from config import (CHROMA_NEWS_COLLECTION, CHROMA_PROFILE_COLLECTION, LOG_DIR)
from database import init_db, executemany, query_one, wipe_all
from database import init_db, executemany, query_one, query, execute, wipe_all
from rag import vector_store as vs
random.seed(42)
@@ -427,6 +427,123 @@ def build_vectors(news, stocks):
print(f" 概况索引条数: {vs.collection_count(CHROMA_PROFILE_COLLECTION)}")
# ===================================================================== 增量更新
def extend_daily(target_date=None):
"""把模拟行情/指数/新闻从库里最新日期增量扩展到最新交易日(幂等,可重复调用)。
返回 (起始日期, 结束日期, 新增交易日数, 新增新闻数) 或 (None, None, 0, 0)。
"""
init_db()
last = query_one("SELECT MAX(date) d FROM stock_daily")
last_date = last["d"] if last else None
today = dt.date.today()
if target_date is None:
target_date = today
if isinstance(target_date, str):
target_date = dt.date.fromisoformat(target_date)
# 最新交易日:今天若是周末则回退到周五
while target_date.weekday() >= 5:
target_date -= dt.timedelta(days=1)
if last_date:
start = dt.date.fromisoformat(last_date) + dt.timedelta(days=1)
else:
start = target_date - dt.timedelta(days=179)
dates = []
d = start
while d <= target_date:
if d.weekday() < 5:
dates.append(d.isoformat())
d += dt.timedelta(days=1)
if not dates:
return None, None, 0, 0
print(f">>> 增量扩展行情 {dates[0]} ~ {dates[-1]}{len(dates)} 个交易日)...")
stocks = query("SELECT code,name,industry,board,total_shares,float_shares FROM stocks")
daily = []
index = {}
price = {}
for s in stocks:
rows = query("SELECT date,close,volume FROM stock_daily WHERE code=? ORDER BY date DESC LIMIT 30",
(s["code"],))
if not rows:
continue
last_close = rows[0]["close"]
base_vol = rows[0]["volume"] or 1
closes = [r["close"] for r in reversed(rows)]
rets = [(closes[i + 1] - closes[i]) / closes[i] for i in range(len(closes) - 1)]
mean_r = sum(rets) / max(len(rets), 1)
vol = (sum((r - mean_r) ** 2 for r in rets) / max(len(rets), 1)) ** 0.5 if rets else 0.02
vol = max(0.005, min(0.05, vol))
drift = 0.0002
p = prev = last_close
for dd in dates:
r = random.gauss(drift, vol)
if random.random() < 0.02:
r += random.gauss(0, vol * 1.6)
prev = p
p = max(0.5, p * (1 + r))
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 + 1.5 * abs(r) / max(vol, 1e-6)) * random.uniform(0.6, 1.4)
amount = volume * (open_p + p) / 2
chg = (p - prev) / prev * 100
daily.append((s["code"], dd, round(open_p, 2), round(high, 2), round(low, 2),
round(p, 2), round(volume, 0), round(amount, 0), round(chg, 2)))
price[s["code"]] = p
# 指数延续
idx_rows = query("SELECT date,sh,sz,cy FROM market_index ORDER BY date DESC LIMIT 1")
if idx_rows:
sh, sz, cy = idx_rows[0]["sh"], idx_rows[0]["sz"], idx_rows[0]["cy"]
else:
sh, sz, cy = 3245.0, 10580.0, 2120.0
for dd in dates:
sh_r = sum(random.gauss(0.0004, 0.008) for _ in range(6)) / 6
sz_r = sh_r + random.gauss(0, 0.004)
cy_r = sh_r + random.gauss(0, 0.006)
sh *= (1 + sh_r); sz *= (1 + sz_r); cy *= (1 + cy_r)
index[dd] = {"sh": round(sh, 2), "sz": round(sz, 2), "cy": round(cy, 2)}
executemany(
"INSERT OR REPLACE INTO stock_daily(code,date,open,high,low,close,volume,amount,change_pct) "
"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 in price:
execute("UPDATE stocks SET market_cap=ROUND((SELECT close FROM stock_daily WHERE code=? ORDER BY date DESC LIMIT 1)*total_shares,2) WHERE code=?",
(code, code))
# 新增新闻(仅保留落在新增日期内的)
news = gen_news(dates, price)
date_set = set(dates)
new_news = [n for n in news if n["publish_date"] in date_set]
if new_news:
executemany(
"INSERT INTO news(title,content,source,category,publish_date,related_stocks,sentiment,is_positive) "
"VALUES(?,?,?,?,?,?,?,?)",
[(n["title"], n["content"], n["source"], n["category"], n["publish_date"],
n["related"], n["sentiment"], n["is_positive"]) for n in new_news])
# 向量追加
ids, docs, metas = [], [], []
for n in new_news:
for code in n["related"].split(","):
ids.append(f"news-{n['publish_date']}-{n['title']}-{code}")
docs.append(f"{n['title']}\n{n['content']}")
metas.append({"code": code, "title": n["title"], "date": n["publish_date"],
"category": n["category"], "sentiment": n["sentiment"], "news_id": 0})
try:
for i in range(0, len(ids), 16):
vs.add_documents(ids[i:i + 16], docs[i:i + 16], metas[i:i + 16], CHROMA_NEWS_COLLECTION)
except Exception as e:
print("新闻向量追加失败(可忽略,下次重灌会重建):", e)
print(f">>> 新增新闻 {len(new_news)} 条(向量已追加)")
return dates[0], dates[-1], len(dates), len(new_news)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--skip-vector", action="store_true", help="跳过向量索引重建")
+50
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
@@ -113,9 +115,57 @@ def tracking_config():
"interval_min": max(15, int(get_setting("tracking_interval", TRACKING_DEFAULTS["tracking_interval"]))),
"notify": _truthy(get_setting("tracking_notify", TRACKING_DEFAULTS["tracking_notify"])),
"impact_threshold": float(get_setting("tracking_impact_threshold", TRACKING_DEFAULTS["tracking_impact_threshold"])),
# 推送方式: aggregate=聚合一起发(默认, 一轮全部分析完统一发一封汇总) / scattered=分散发(每只出结果立即单独发)
"push_mode": (get_setting("tracking_push_mode", "aggregate") or "aggregate").strip().lower(),
}
# ===================================================================== 静默期
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", ""),
+39 -1
View File
@@ -22,6 +22,12 @@ async function refreshStats() {
<span>🏢 公司概况索引 <span style="color:var(--muted)">(stock_profiles_v1)</span></span>
<span class="num"><b>${d.vector.profiles}</b> 条</span>
</div>
<div class="between" style="padding:7px 0;border-bottom:1px solid #1f2630">
<span>行情最新日期</span><span class="num"><b>${d.latest_daily || '—'}</b></span>
</div>
<div class="between" style="padding:7px 0;border-bottom:1px solid #1f2630">
<span>新闻最新日期</span><span class="num"><b>${d.latest_news || '—'}</b></span>
</div>
<div class="between" style="padding:7px 0">
<span>数据模式</span>
<span class="tag ${d.is_mock ? 'tag-观望' : 'tag-推荐'}">${d.is_mock ? '模拟数据' : '真实数据'}</span>
@@ -52,6 +58,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 +88,7 @@ async function loadReportLog() {
<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><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) {}
@@ -78,6 +104,17 @@ async function reseed() {
}
}
async function updateData() {
try {
const r = await api('/api/admin/update-data', { method: 'POST' });
toast(r.msg || '行情更新已启动');
setTimeout(refreshStats, 8000);
setTimeout(refreshStats, 20000);
} catch (e) {
toast('启动失败:' + e.message);
}
}
async function health() {
$('#healthBox').innerHTML = '<div class="loading"><span class="spin"></span> 检查中…</div>';
try {
@@ -96,3 +133,4 @@ async function health() {
refreshStats();
loadReportLog();
loadReportQuiet();
+31 -4
View File
@@ -21,12 +21,21 @@ 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;
$('#trackingPushMode').value = (tr.push_mode === 'scattered') ? 'scattered' : 'aggregate';
renderTrackingState(tr);
// 静默期(跟踪)
const tq = (autoSettings.quiet && autoSettings.quiet.tracking) || {};
$('#trackingQuietEnabled').checked = !!tq.enabled;
$('#trackingQuietRanges').value = tq.ranges || '';
loadLog();
loadTrackReports();
loadPositions();
@@ -61,10 +70,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); }
}
@@ -75,8 +101,9 @@ async function saveTracking() {
tracking_enabled: $('#trackingEnabled').checked,
tracking_interval: $('#trackingInterval').value,
tracking_impact_threshold: $('#trackingImpact').value,
tracking_notify: $('#trackingNotify').checked
} }) });
tracking_notify: $('#trackingNotify').checked,
tracking_push_mode: $('#trackingPushMode').value
}, quiet: { tracking: collectTrackingQuiet() } }) });
toast(r.msg || '已保存');
} catch (e) { toast('保存失败:' + e.message); }
}
@@ -206,7 +233,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) {}
}
+14 -1
View File
@@ -23,6 +23,17 @@
自动触发已配置系统 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>
@@ -36,13 +47,15 @@
<div class="card-title"><span class="bar" style="background:var(--gold)"></span>系统维护</div>
<div class="flex wrap">
<button class="btn btn-primary" onclick="reseed()">♻️ 一键重灌数据(含向量重建)</button>
<button class="btn btn-primary" onclick="updateData()">📅 更新行情到最新交易日</button>
<button class="btn" onclick="rebuildBt()">📈 重建量化回测</button>
<button class="btn" onclick="health()">🔌 依赖连通性检查</button>
<button class="btn" onclick="refreshStats()">🔄 刷新统计</button>
</div>
<div id="healthBox" class="mt16"></div>
<div class="mt16" style="border-top:1px solid var(--border);padding-top:14px;color:var(--muted);font-size:13px;line-height:2">
<b>当前为模拟数据</b>,用于功能演示与系统验证。接入真实数据后(行情/新闻/机构),修改 <code>config.py</code> 中的 <code>IS_MOCK=False</code>,并替换 <code>seed_data.py</code> 为真实数据源即可,其余分析/检索逻辑无需改动<br>
<b>当前为模拟数据</b>,用于功能演示与系统验证。<b>📅 更新行情</b>会把模拟行情/新闻/指数从库内最新日期<b>增量延续</b>到最新交易日(服务每小时自动检查一次,无需手动)<br>
接入真实数据后(行情/新闻/机构),修改 <code>config.py</code> 中的 <code>IS_MOCK=False</code>,并替换 <code>seed_data.py</code> 为真实数据源即可,其余分析/检索逻辑无需改动。<br>
技术栈:Python Flask + SQLite + Chroma 向量库 + bge-large-zh 语义检索 + DeepSeek 大模型(RAG 增强研报)。
</div>
</div>
+44 -14
View File
@@ -12,22 +12,32 @@
<div class="tab-panel" id="panel-monitor">
<div class="between mt16">
<div class="card-title" style="margin-bottom:0"><span class="bar" style="background:var(--cyan)"></span>定期扫描最新新闻,重要信息邮件通知</div>
<label class="switch"><input type="checkbox" id="monitorEnabled"><span></span></label>
<label class="switch"><input type="checkbox" id="monitorEnabled" onchange="saveMonitor()"><span></span></label>
</div>
<div class="mt8 settings-form">
<div class="sf-row"><label>扫描间隔(分钟)</label><input class="input" id="monitorInterval" type="number" min="5" style="width:120px"></div>
<div class="sf-row"><label>重要度阈值 (0-100)</label><input class="input" id="monitorImportance" type="number" min="0" max="100" style="width:120px"></div>
<div class="sf-row"><label>情感权重</label><input class="input" id="monitorSentiment" type="number" step="0.05" style="width:120px"></div>
<div class="sf-row"><label>扫描间隔(分钟)</label><input class="input" id="monitorInterval" type="number" min="5" style="width:120px" onchange="saveMonitor()"></div>
<div class="sf-row"><label>重要度阈值 (0-100)</label><input class="input" id="monitorImportance" type="number" min="0" max="100" style="width:120px" onchange="saveMonitor()"></div>
<div class="sf-row"><label>情感权重</label><input class="input" id="monitorSentiment" type="number" step="0.05" style="width:120px" onchange="saveMonitor()"></div>
<div class="sf-row" style="align-items:flex-start"><label>监控分类</label>
<div class="chk-group" id="monitorCats">
<label><input type="checkbox" value="公司"> 公司</label>
<label><input type="checkbox" value="业绩"> 业绩</label>
<label><input type="checkbox" value="机构观点"> 机构观点</label>
<label><input type="checkbox" value="行业"> 行业</label>
<label><input type="checkbox" value="市场"> 市场</label>
<label><input type="checkbox" value="公司" onchange="saveMonitor()"> 公司</label>
<label><input type="checkbox" value="业绩" onchange="saveMonitor()"> 业绩</label>
<label><input type="checkbox" value="机构观点" onchange="saveMonitor()"> 机构观点</label>
<label><input type="checkbox" value="行业" onchange="saveMonitor()"> 行业</label>
<label><input type="checkbox" value="市场" onchange="saveMonitor()"> 市场</label>
</div>
</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 class="sf-row" style="align-items:flex-start"><label>关键词(逗号分隔)</label><textarea class="input" id="monitorKeywords" rows="2" style="width:100%" onchange="saveMonitor()"></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" onchange="saveMonitor()"><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%" onchange="saveMonitor()"></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>
@@ -49,14 +59,34 @@
<div class="tab-panel" id="panel-tracking" style="display:none">
<div class="between mt16">
<div class="card-title" style="margin-bottom:0"><span class="bar" style="background:#8b5cf6"></span>AI 智能体定期跟踪持仓,深度分析产业链上下游</div>
<label class="switch"><input type="checkbox" id="trackingEnabled"><span></span></label>
<label class="switch"><input type="checkbox" id="trackingEnabled" onchange="saveTracking()"><span></span></label>
</div>
<div class="mt8 settings-form">
<div class="sf-row"><label>跟踪间隔(分钟)</label><input class="input" id="trackingInterval" type="number" min="15" style="width:120px"></div>
<div class="sf-row"><label>重大判定阈值</label><input class="input" id="trackingImpact" type="number" min="0" max="100" style="width:120px"></div>
<div class="sf-row"><label>跟踪间隔(分钟)</label><input class="input" id="trackingInterval" type="number" min="15" style="width:120px" onchange="saveTracking()"></div>
<div class="sf-row"><label>重大判定阈值</label><input class="input" id="trackingImpact" type="number" min="0" max="100" style="width:120px" onchange="saveTracking()"></div>
<div class="sf-row"><label>重大变化邮件通知</label>
<label class="switch"><input type="checkbox" id="trackingNotify"><span></span></label>
<label class="switch"><input type="checkbox" id="trackingNotify" onchange="saveTracking()"><span></span></label>
</div>
<div class="sf-row"><label>重大变化推送方式</label>
<select class="input" id="trackingPushMode" style="width:150px" onchange="saveTracking()">
<option value="aggregate">聚合一起发</option>
<option value="scattered">分散发</option>
</select>
</div>
<div style="color:var(--muted);font-size:12px;line-height:1.8">
聚合一起发(默认):一轮跟踪全部完成后再统一发一封汇总邮件,不刷屏;<br>
分散发:每只股票分析完出结果立即单独发一封邮件。
</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" onchange="saveTracking()"><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%" onchange="saveTracking()"></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>