diff --git a/app.py b/app.py
index 791d5fe..ddbe662 100644
--- a/app.py
+++ b/app.py
@@ -741,6 +741,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():
diff --git a/database.py b/database.py
index a553c86..53c7a13 100644
--- a/database.py
+++ b/database.py
@@ -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:
diff --git a/engine/notifier.py b/engine/notifier.py
index dc575d7..3af96c7 100644
--- a/engine/notifier.py
+++ b/engine/notifier.py
@@ -30,11 +30,25 @@ STRONG_WORDS = ["回购", "中标", "减持", "问询", "停牌", "重组", "预
# ===================================================================== 邮件
-def send_email(subject, html_body, to=None, cfg=None, sender_name=None):
- """发送 HTML 邮件。cfg 来自设置;失败抛异常(调用方捕获)"""
+def send_email(subject, html_body, to=None, cfg=None, sender_name=None, attachments=None):
+ """发送 HTML 邮件。cfg 来自设置;attachments: [{filename, content(bytes)}];失败抛异常"""
+ from email.mime.multipart import MIMEMultipart
+ from email.mime.base import MIMEBase
+ from email import encoders
cfg = cfg or mail_config()
to = to or cfg["email_to"]
- msg = MIMEText(html_body, "html", "utf-8")
+ if attachments:
+ msg = MIMEMultipart()
+ msg.attach(MIMEText(html_body, "html", "utf-8"))
+ for att in attachments:
+ part = MIMEBase("application", "octet-stream")
+ part.set_payload(att.get("content") or b"")
+ encoders.encode_base64(part)
+ part.add_header("Content-Disposition", "attachment",
+ filename=("utf-8", "", att.get("filename", "report.html")))
+ msg.attach(part)
+ else:
+ msg = MIMEText(html_body, "html", "utf-8")
msg["From"] = formataddr((sender_name or cfg["sender_name"], cfg["smtp_user"]))
msg["To"] = to
msg["Subject"] = subject
diff --git a/engine/report.py b/engine/report.py
new file mode 100644
index 0000000..aabbf45
--- /dev/null
+++ b/engine/report.py
@@ -0,0 +1,463 @@
+# -*- coding: utf-8 -*-
+"""
+每日行情报告引擎(盘前 / 盘后)
+- premarket : 工作日 9:00 —— 昨日市场回顾 / 昨日至今要闻 / 全球市场 / 持仓与关注目标 / 盘前研判
+- postmarket : 交易日 15:30 —— 今日市场总结 / 今日要闻 / 全球市场 / 持仓表现 / 盘后研判
+每期输出两份报告:
+ 简单版 —— 邮件正文(HTML,快速浏览)
+ 详细版 —— HTML 附件(完整结构化 + AI 深度解读)
+"""
+import datetime as dt
+import html as html_mod
+import json
+import logging
+import time
+
+from database import query, query_one, execute
+from settings import mail_config
+from engine.analyst import llm_chat
+
+log = logging.getLogger("report")
+
+GLOBAL_ORDER = ["dji", "nasdaq", "sp500", "hsi", "nikkei", "kospi", "dax", "cac", "ftse"]
+KIND_META = {
+ "premarket": {"name": "盘前分析", "scope": "昨日与今日", "title": "盘前 · 昨日市场回顾与今日展望"},
+ "postmarket": {"name": "盘后总结", "scope": "今日", "title": "盘后 · 今日市场总结"},
+}
+
+
+# ===================================================================== 数据采集
+def latest_trading_day():
+ r = query_one("SELECT MAX(date) d FROM stock_daily")
+ return r["d"] if r else dt.date.today().isoformat()
+
+
+def collect_market(day):
+ """指数 / 涨跌 / 量能 / 行业 / 个股"""
+ idx = query("SELECT * FROM market_index WHERE date<=? ORDER BY date DESC LIMIT 2", (day,))
+ latest = idx[0] if idx else {}
+ prev = idx[1] if len(idx) > 1 else latest
+ inds = []
+ for k, label in (("sh", "上证指数"), ("sz", "深证成指"), ("cy", "创业板指")):
+ cur, old = latest.get(k, 0), prev.get(k, 0) or 1
+ inds.append({"key": k, "label": label, "value": cur,
+ "chg": round((cur - old) / old * 100, 2)})
+ stat = query_one(
+ "SELECT COUNT(*) total, SUM(CASE WHEN change_pct>0 THEN 1 ELSE 0 END) up,"
+ "SUM(CASE WHEN change_pct<0 THEN 1 ELSE 0 END) down,"
+ "SUM(CASE WHEN change_pct>=9.8 THEN 1 ELSE 0 END) limit_up,"
+ "SUM(CASE WHEN change_pct<=-9.8 THEN 1 ELSE 0 END) limit_down,"
+ "ROUND(SUM(amount)/10000,2) amount_yi "
+ "FROM stock_daily WHERE date=?", (day,))
+ heat = query(
+ "SELECT s.industry, ROUND(AVG(d.change_pct),2) chg, COUNT(*) cnt "
+ "FROM stock_daily d JOIN stocks s ON s.code=d.code WHERE d.date=? "
+ "GROUP BY s.industry ORDER BY chg DESC", (day,))
+ gainers = query(
+ "SELECT s.name, s.code, s.industry, d.change_pct FROM stock_daily d "
+ "JOIN stocks s ON s.code=d.code WHERE d.date=? ORDER BY d.change_pct DESC LIMIT 8", (day,))
+ losers = query(
+ "SELECT s.name, s.code, s.industry, d.change_pct FROM stock_daily d "
+ "JOIN stocks s ON s.code=d.code WHERE d.date=? ORDER BY d.change_pct ASC LIMIT 8", (day,))
+ return {"date": day, "indexes": inds, "stat": stat, "heat": heat,
+ "gainers": gainers, "losers": losers}
+
+
+def collect_news(since_date, limit=20):
+ rows = query(
+ "SELECT id,title,content,source,category,publish_date,sentiment,related_stocks FROM news "
+ "WHERE publish_date>=? ORDER BY publish_date DESC, id DESC LIMIT ?", (since_date, limit))
+ # 按重要度排序(类别权重 + 情感强度)
+ w = {"公司": 3, "业绩": 3, "机构观点": 2, "行业": 2, "市场": 1}
+ for n in rows:
+ n["_score"] = w.get(n["category"], 1) * 10 + abs(n["sentiment"]) * 5
+ rows.sort(key=lambda x: x["_score"], reverse=True)
+ return rows
+
+
+def collect_positions():
+ rows = query(
+ "SELECT w.code, s.name, s.industry, s.market_cap, d.close, d.change_pct "
+ "FROM watchlist w JOIN stocks s ON s.code=w.code "
+ "LEFT JOIN stock_daily d ON d.code=s.code AND d.date=(SELECT MAX(date) FROM stock_daily) "
+ "ORDER BY w.added_at")
+ out = []
+ for r in rows:
+ sc = query_one(
+ "SELECT AVG(sentiment) s FROM news WHERE (related_stocks=? OR related_stocks LIKE ? OR related_stocks LIKE ?) "
+ "AND publish_date>=date('now','-7 day')", (r["code"], f"%,{r['code']}", f"{r['code']},%"))
+ out.append({**r, "news_score": round(sc["s"], 2) if sc and sc["s"] is not None else 0})
+ return out
+
+
+def collect_targets():
+ tgts = query("SELECT id, type, code, name, keywords FROM watch_targets WHERE enabled=1")
+ out = []
+ for t in tgts:
+ if t["type"] == "stock" and t["code"]:
+ latest = query_one(
+ "SELECT meta, created_at FROM tracking_reports WHERE code=? ORDER BY id DESC LIMIT 1", (t["code"],))
+ else:
+ latest = query_one(
+ "SELECT meta, created_at FROM tracking_reports WHERE code=? ORDER BY id DESC LIMIT 1",
+ (f"CONCEPT:{t['name']}",))
+ m = json.loads(latest["meta"]) if latest else {}
+ out.append({"type": t["type"], "name": t["name"],
+ "impact": m.get("impact_score"), "change_kind": m.get("change_kind"),
+ "summary": m.get("summary", ""), "tracked_at": latest["created_at"] if latest else None})
+ return out
+
+
+def collect_global():
+ r = query_one("SELECT date, data FROM global_markets ORDER BY date DESC LIMIT 1")
+ if not r:
+ return []
+ try:
+ data = json.loads(r["data"])
+ except Exception:
+ return []
+ items = []
+ for k in GLOBAL_ORDER:
+ if k in data:
+ items.append(data[k])
+ return items
+
+
+# ===================================================================== 文本渲染
+def fmt_market(mkt):
+ s = mkt["stat"] or {}
+ idx_txt = " ".join(f"{i['label']} {i['value']:.2f} ({i['chg']:+.2f}%)" for i in mkt["indexes"])
+ heat_txt = "、".join(f"{h['industry']}({h['chg']:+.2f}%)" for h in mkt["heat"][:6]) or "无"
+ g_txt = "、".join(f"{g['name']}({g['change_pct']:+.2f}%)" for g in mkt["gainers"][:5])
+ l_txt = "、".join(f"{g['name']}({g['change_pct']:+.2f}%)" for g in mkt["losers"][:5])
+ return {
+ "idx": idx_txt,
+ "breadth": (f"上涨 {s.get('up',0)} / 下跌 {s.get('down',0)} 家,"
+ f"涨停 {s.get('limit_up',0)} / 跌停 {s.get('limit_down',0)},"
+ f"两市成交 {s.get('amount_yi',0)} 亿"),
+ "heat": heat_txt,
+ "gainers": g_txt or "无",
+ "losers": l_txt or "无",
+ }
+
+
+def fmt_news(news, top=8):
+ lines = []
+ for n in news[:top]:
+ tone = "利好" if n["sentiment"] > 0 else ("利空" if n["sentiment"] < 0 else "中性")
+ lines.append(f"- [{n['publish_date']}] {n['title']}({n['category']}·{tone}{n['sentiment']:+.2f}){n['content'][:60]}")
+ return "\n".join(lines) or "(暂无)"
+
+
+def fmt_positions(pos):
+ if not pos:
+ return "(当前无持仓/自选股)"
+ return "\n".join(
+ f"- {p['name']}({p['code']}) {p['industry']} 收盘{p['close']} ({p['change_pct']:+.2f}%) 市值{p['market_cap']:.0f}亿 近7日消息面{p['news_score']:+.2f}"
+ for p in pos)
+
+
+def fmt_targets(tgts):
+ if not tgts:
+ return "(当前无跟踪目标)"
+ return "\n".join(
+ f"- [{t['type']}] {t['name']} 影响度{t['impact'] or '--'}/100 {t['change_kind'] or ''} {t['summary'][:50]}"
+ for t in tgts)
+
+
+def fmt_global(items):
+ return " ".join(f"{g.get('label','')} {g.get('value',0):.2f} ({g.get('chg',0):+.2f}%)" for g in items) or "(暂无)"
+
+
+# ===================================================================== 生成报告
+def _build_context(kind):
+ day = latest_trading_day()
+ mkt = collect_market(day)
+ fm = fmt_market(mkt)
+ if kind == "premarket":
+ news = collect_news(day, limit=24)
+ scope_txt = "昨日/最近交易日"
+ else:
+ news = collect_news(day, limit=24)
+ scope_txt = "今日"
+ pos = collect_positions()
+ tgts = collect_targets()
+ glob = collect_global()
+ ctx = {
+ "kind_name": KIND_META[kind]["name"],
+ "date": day,
+ "scope": scope_txt,
+ "mkt": mkt, "fm": fm,
+ "news": news, "news_txt": fmt_news(news, 10),
+ "pos": pos, "pos_txt": fmt_positions(pos),
+ "tgts": tgts, "tgts_txt": fmt_targets(tgts),
+ "global_txt": fmt_global(glob),
+ "global": glob,
+ }
+ return ctx
+
+
+def _base_prompt(ctx, detailed):
+ d = ctx["date"]
+ title = KIND_META[ctx["kind_name"] if ctx["kind_name"] in KIND_META else "premarket"]["title"] if False else ""
+ kind = "盘前分析" if "盘前" in ctx["kind_name"] else "盘后总结"
+ return f"""你是资深A股市场分析师,请基于下方【数据】生成一份{kind}报告。
+
+【报告日期】{d}
+【指数】{ctx['fm']['idx']}
+【涨跌结构】{ctx['fm']['breadth']}
+【领涨行业】{ctx['fm']['heat']}
+【领涨个股】{ctx['fm']['gainers']}
+【领跌个股】{ctx['fm']['losers']}
+【重点要闻】
+{ctx['news_txt']}
+【全球市场】
+{ctx['global_txt']}
+【持仓/自选股】
+{ctx['pos_txt']}
+【关注目标/主题】
+{ctx['tgts_txt']}
+"""
+
+
+def _brief_prompt(ctx):
+ return _base_prompt(ctx, False) + """
+【输出要求】输出一份精炼的盘前/盘后速览(约 200-300 字),Markdown 格式,包含:
+1. 一句话大盘研判
+2. 3-5 条关键要点(行情/消息/持仓/主题)
+3. 今日关注提示
+要求信息密集、数据准确,不要编造数据。"""
+
+
+def _detail_prompt(ctx):
+ return _base_prompt(ctx, True) + """
+【输出要求】输出一份完整的盘前/盘后分析报告(Markdown),结构如下:
+## 一、市场概览(指数表现/涨跌结构/量能/领涨领跌板块个股解读)
+## 二、消息面解析(分类解读重点要闻及影响:政策/行业/公司/机构观点)
+## 三、全球市场联动(外围市场表现及对A股的传导)
+## 四、持仓表现(逐只点评:涨跌、评分依据、近期消息面)
+## 五、关注目标/主题(各主题/个股的最新动态与影响度解读)
+## 六、操作策略与风险提示
+数据须严格来自上文【数据】,可补充合理分析逻辑,不得编造数字。"""
+
+
+def generate_reports(kind):
+ """生成 (brief_html, detail_html)"""
+ ctx = _build_context(kind)
+ brief_md = ""
+ detail_md = ""
+ try:
+ brief_md = llm_chat([
+ {"role": "system", "content": "你是一名严谨专业的A股市场分析师。"},
+ {"role": "user", "content": _brief_prompt(ctx)},
+ ]).strip()
+ except Exception as e:
+ log.warning("brief llm fail: %s", e)
+ try:
+ detail_md = llm_chat([
+ {"role": "system", "content": "你是一名严谨专业的A股市场分析师。"},
+ {"role": "user", "content": _detail_prompt(ctx)},
+ ]).strip()
+ except Exception as e:
+ log.warning("detail llm fail: %s", e)
+
+ brief_html = _render_brief(ctx, brief_md)
+ detail_html = _render_detail(ctx, detail_md)
+ return brief_html, detail_html
+
+
+# ===================================================================== 渲染
+def _render_brief(ctx, brief_md):
+ kind = KIND_META[ctx["kind_name"] if ctx["kind_name"] in KIND_META else "premarket"]
+ rows = []
+ for i in ctx["mkt"]["indexes"]:
+ rows.append(f"=0 else '#17a34a'}'>{i['label']} {i['value']:.2f} ({i['chg']:+.2f}%)")
+ news_li = "".join(f"
[{n['publish_date']}] {html_mod.escape(n['title'])} ({n['category']})"
+ for n in ctx["news"][:6]) or "暂无"
+ pos_li = "".join(f"{p['name']}({p['code']}) 收{p['close']} "
+ f"=0 else '#17a34a'}'>{p['change_pct']:+.2f}% · {p['industry']}"
+ for p in ctx["pos"]) or "暂无持仓"
+ tgt_li = "".join(f"{html_mod.escape(t['name'])}(影响度{t['impact'] or '--'},{t['change_kind'] or '—'})"
+ for t in ctx["tgts"][:5]) or "暂无目标"
+ gb = " | ".join(f"{g.get('label','')} {g.get('value',0):.2f} "
+ f"=0 else '#17a34a'}'>({g.get('chg',0):+.2f}%)" for g in ctx["global"][:6])
+ ai = html_mod.escape(brief_md) if brief_md else "(AI 简评生成失败,请查看附件详细版)"
+ return f"""
+
+
+
📊 智能荐股 · {kind['name']}
+
{ctx['date']} · 简版速览 · 详细版见附件
+
+
+
🔎 大盘:
+
{' '.join(rows)}
+
{ctx['fm']['breadth']}
+
领涨行业:{ctx['fm']['heat']}
+
+
📰 重点要闻:
+
+
+
🌏 全球市场:
+
{gb}
+
+
💼 持仓:
+
+
+
🎯 关注目标/主题:
+
+
+
🤖 AI 研判:
+
{ai}
+
+
+ 智能荐股系统自动生成 · 内容基于模拟数据,仅供演示,不构成投资建议
+
"""
+
+
+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(""); in_list = False
+ continue
+ if line.startswith("## "):
+ if in_list:
+ out.append(""); in_list = False
+ out.append(f"{line[3:]}
")
+ elif line.startswith("### "):
+ if in_list:
+ out.append(""); in_list = False
+ out.append(f"{line[4:]}
")
+ elif line.startswith("##"):
+ if in_list:
+ out.append(""); in_list = False
+ out.append(f"{line[2:].strip()}
")
+ elif line.startswith("- "):
+ if not in_list:
+ out.append(""); in_list = True
+ out.append(f"- {line[2:]}
")
+ elif line.startswith("# "):
+ if in_list:
+ out.append("
"); in_list = False
+ out.append(f"{line[2:]}
")
+ else:
+ if in_list:
+ out.append(""); in_list = False
+ out.append(f"{line}
")
+ if in_list:
+ out.append("")
+ 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"| {i['label']} | {i['value']:.2f} | "
+ f"=0 else '#17a34a'}'>{i['chg']:+.2f}% |
"
+ for i in ctx["mkt"]["indexes"])
+ stat = ctx["mkt"]["stat"] or {}
+ heat_rows = "".join(f"| {h['industry']} | {h['cnt']} | "
+ f"=0 else '#17a34a'}'>{h['chg']:+.2f}% |
"
+ for h in ctx["mkt"]["heat"])
+ g_rows = "".join(f"| {g['name']} | {g['code']} | "
+ f"=0 else '#17a34a'}'>{g['change_pct']:+.2f}% |
"
+ for g in ctx["mkt"]["gainers"])
+ l_rows = "".join(f"| {g['name']} | {g['code']} | "
+ f"=0 else '#17a34a'}'>{g['change_pct']:+.2f}% |
"
+ for g in ctx["mkt"]["losers"])
+ news_rows = "".join(
+ f"| {n['publish_date']} | {n['category']} | {html_mod.escape(n['title'])} | "
+ f"=0 else '#17a34a'}'>{n['sentiment']:+.2f} |
"
+ for n in ctx["news"][:15])
+ pos_rows = "".join(
+ f"| {p['name']}{p['code']} | {p['industry']} | {p['close']} | "
+ f"=0 else '#17a34a'}'>{p['change_pct']:+.2f}% | "
+ f"{p['market_cap']:.0f}亿 | {p['news_score']:+.2f} |
"
+ for p in ctx["pos"]) or "| 暂无持仓 |
"
+ gb_rows = "".join(f"| {g.get('label','')} | {g.get('value',0):.2f} | "
+ f"=0 else '#17a34a'}'>{g.get('chg',0):+.2f}% |
"
+ for g in ctx["global"])
+ body = _md_to_html(detail_md) if detail_md else "(AI 分析生成失败)
"
+ return f"""
+智能荐股 · {kind['name']} {ctx['date']}
+
+
+
📊 智能荐股 · {kind['name']}({ctx['date']})
+
市场/要闻/全球/持仓/主题 全景分析 · 详细版报告 · 自动生成
+
+
+
〇、数据总览
+
指数
+
涨跌结构:{ctx['fm']['breadth']}
+
+
+
重点要闻
+
+
全球市场
+
+
持仓 / 自选股
+
+
关注目标 / 主题
+ {html_mod.escape(ctx['tgts_txt']).replace(chr(10), '
')}
+
AI 深度分析
+ {body}
+
+
+
"""
+
+
+# ===================================================================== 发送
+def send_daily_report(kind="premarket"):
+ """生成并发送报告:正文=简版,附件=详细版 HTML。返回 dict 状态"""
+ from engine.notifier import send_email
+ mc = mail_config()
+ brief_html, detail_html = generate_reports(kind)
+ meta = KIND_META.get(kind, KIND_META["premarket"])
+ subject = f"[智能荐股] {meta['name']} {time.strftime('%Y-%m-%d')}"
+ detail_file = f"智能荐股_{meta['name']}_{time.strftime('%Y%m%d')}.html"
+ try:
+ send_email(subject, brief_html, cfg=mc,
+ attachments=[{"filename": detail_file, "content": detail_html.encode("utf-8")}])
+ execute("INSERT INTO report_log(kind, subject, brief_len, detail_len, status, message) "
+ "VALUES(?,?,?,?,'sent','附件: '||?)",
+ (kind, subject, len(brief_html), len(detail_html), detail_file))
+ return {"ok": True, "subject": subject, "detail_file": detail_file}
+ except Exception as e:
+ execute("INSERT INTO report_log(kind, subject, brief_len, detail_len, status, message) "
+ "VALUES(?,?,?,?,'failed',?)",
+ (kind, subject, len(brief_html), len(detail_html), str(e)))
+ return {"ok": False, "error": str(e)}
+
+
+def report_log(limit=20):
+ return query("SELECT * FROM report_log ORDER BY id DESC LIMIT ?", (limit,))
+
+
+if __name__ == "__main__":
+ import sys
+ kind = sys.argv[1] if len(sys.argv) > 1 else "premarket"
+ if kind not in ("premarket", "postmarket"):
+ kind = "premarket"
+ logging.basicConfig(level=logging.INFO)
+ r = send_daily_report(kind)
+ print(r)
diff --git a/reports.py b/reports.py
new file mode 100644
index 0000000..e162685
--- /dev/null
+++ b/reports.py
@@ -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)
diff --git a/seed_data.py b/seed_data.py
index 7ff48aa..c033e98 100644
--- a/seed_data.py
+++ b/seed_data.py
@@ -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
diff --git a/static/js/admin.js b/static/js/admin.js
index d7e4624..be0d4b8 100644
--- a/static/js/admin.js
+++ b/static/js/admin.js
@@ -40,6 +40,34 @@ async function rebuildBt() {
} catch (e) { toast('启动失败'); }
}
+/* 定时报告 */
+async function sendReport(kind) {
+ const btn = event.target; btn.disabled = true;
+ try {
+ const r = await api('/api/report/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind }) });
+ toast(r.msg || '已启动');
+ setTimeout(loadReportLog, 60000);
+ setTimeout(loadReportLog, 150000);
+ } catch (e) { toast('启动失败:' + e.message); }
+ btn.disabled = false;
+}
+
+async function loadReportLog() {
+ try {
+ const d = await api('/api/report/log');
+ const items = d.items || [];
+ if (!items.length) return;
+ $('#reportLogTb').innerHTML = items.map(r => `
+
+ | ${r.sent_at} |
+ ${r.kind === 'premarket' ? '🌅 盘前' : '🌇 盘后'} |
+ ${escapeHtml(r.subject)} |
+ ${r.status} |
+ ${escapeHtml(r.message || '')} |
+
`).join('');
+ } catch (e) {}
+}
+
async function reseed() {
if (!confirm('将清空全部业务数据并重新生成(含向量索引重建,需 1-3 分钟),确定继续?')) return;
try {
@@ -67,3 +95,4 @@ async function health() {
}
refreshStats();
+loadReportLog();
diff --git a/templates/admin.html b/templates/admin.html
index e22595b..93cbfe3 100644
--- a/templates/admin.html
+++ b/templates/admin.html
@@ -13,6 +13,25 @@
+
+
📧 定时报告(工作日 9:00 盘前 / 交易日 15:30 盘后)
+
+
+
+
+
+ 自动触发已配置系统 cron:0 9 * * 1-5 盘前 · 30 15 * * 1-5 盘后。
+ 每期发送两封形态:简单版(邮件正文速览)+ 详细版(HTML 附件)。发送记录见下方表格。
+
+
发送记录
+
+
+