From adc54d509f2abd401225b2d4390514aaafa779d4 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Thu, 20 Aug 2026 00:08:39 +0800 Subject: [PATCH] =?UTF-8?q?v1.7.0:=20=E6=AF=8F=E6=97=A5=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E6=8A=A5=E5=91=8A(=E5=B7=A5=E4=BD=9C=E6=97=A59:00=E7=9B=98?= =?UTF-8?q?=E5=89=8D=E5=88=86=E6=9E=90/15:30=E7=9B=98=E5=90=8E=E6=80=BB?= =?UTF-8?q?=E7=BB=93)=20-=20=E5=85=A8=E7=90=83=E5=B8=82=E5=9C=BA=E6=95=B0?= =?UTF-8?q?=E6=8D=AE+=E6=8A=A5=E5=91=8A=E5=BC=95=E6=93=8E(=E7=AE=80?= =?UTF-8?q?=E7=89=88=E9=82=AE=E4=BB=B6=E6=AD=A3=E6=96=87+=E8=AF=A6?= =?UTF-8?q?=E7=BB=86=E7=89=88HTML=E9=99=84=E4=BB=B6)+cron=E5=AE=9A?= =?UTF-8?q?=E6=97=B6+=E6=89=8B=E5=8A=A8=E8=A7=A6=E5=8F=91+=E5=8F=91?= =?UTF-8?q?=E9=80=81=E6=97=A5=E5=BF=97;=20=E4=BF=AE=E5=A4=8D=E6=A8=A1?= =?UTF-8?q?=E6=8B=9F=E6=95=B0=E6=8D=AE=E6=8D=A2=E6=89=8B=E7=8E=87=E5=A4=B1?= =?UTF-8?q?=E7=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 26 +++ database.py | 18 +- engine/notifier.py | 20 +- engine/report.py | 463 +++++++++++++++++++++++++++++++++++++++++++ reports.py | 28 +++ seed_data.py | 34 +++- static/js/admin.js | 29 +++ templates/admin.html | 19 ++ 8 files changed, 631 insertions(+), 6 deletions(-) create mode 100644 engine/report.py create mode 100644 reports.py 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']}
    +
    +
    📰 重点要闻:
    +
      {news_li}
    +
    +
    🌏 全球市场:
    +
    {gb}
    +
    +
    💼 持仓:
    +
      {pos_li}
    +
    +
    🎯 关注目标/主题:
    +
      {tgt_li}
    +
    +
    🤖 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 = 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']})

    +
    市场/要闻/全球/持仓/主题 全景分析 · 详细版报告 · 自动生成
    +
    +
    +

    〇、数据总览

    +
    指数{idx_rows}
    指数收盘涨跌
    + 涨跌结构:{ctx['fm']['breadth']}
    +
    行业热度{heat_rows}
    行业家数平均涨跌
    +
    领涨个股{g_rows}
    名称代码涨跌
    + 领跌个股{l_rows}
    名称代码涨跌
    +

    重点要闻

    + {news_rows}
    日期分类标题情感
    +

    全球市场

    + {gb_rows}
    指数点位涨跌
    +

    持仓 / 自选股

    + {pos_rows}
    股票行业收盘涨跌市值消息面
    +

    关注目标 / 主题

    + {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 附件)。发送记录见下方表格。 +
    +
    发送记录
    +
    + + + +
    时间类型主题状态说明
    暂无记录
    +
    +
    +
    系统维护