# -*- coding: utf-8 -*-
"""
每日行情报告引擎(盘前 / 盘后)
- premarket : 工作日 9:00 —— 昨日市场回顾 / 昨日至今要闻 / 全球市场 / 持仓与关注目标 / 盘前研判
- postmarket : 交易日 15:30 —— 今日市场总结 / 今日要闻 / 全球市场 / 持仓表现 / 盘后研判
每期输出两份报告:
简单版 —— 邮件正文(HTML,快速浏览)
详细版 —— HTML 附件(完整结构化 + AI 深度解读)
"""
import datetime as dt
import html as html_mod
import json
import logging
import time
from database import query, query_one, execute
from settings import mail_config, quiet_config, in_quiet_period
from engine.analyst import llm_chat
log = logging.getLogger("report")
GLOBAL_ORDER = ["dji", "nasdaq", "sp500", "hsi", "nikkei", "kospi", "dax", "cac", "ftse"]
KIND_META = {
"premarket": {"name": "盘前分析", "scope": "昨日与今日", "title": "盘前 · 昨日市场回顾与今日展望"},
"postmarket": {"name": "盘后总结", "scope": "今日", "title": "盘后 · 今日市场总结"},
}
# ===================================================================== 数据采集
def latest_trading_day():
r = query_one("SELECT MAX(date) d FROM stock_daily")
return r["d"] if r else dt.date.today().isoformat()
def collect_market(day):
"""指数 / 涨跌 / 量能 / 行业 / 个股"""
idx = query("SELECT * FROM market_index WHERE date<=? ORDER BY date DESC LIMIT 2", (day,))
latest = idx[0] if idx else {}
prev = idx[1] if len(idx) > 1 else latest
inds = []
for k, label in (("sh", "上证指数"), ("sz", "深证成指"), ("cy", "创业板指")):
cur, old = latest.get(k, 0), prev.get(k, 0) or 1
inds.append({"key": k, "label": label, "value": cur,
"chg": round((cur - old) / old * 100, 2)})
stat = query_one(
"SELECT COUNT(*) total, SUM(CASE WHEN change_pct>0 THEN 1 ELSE 0 END) up,"
"SUM(CASE WHEN change_pct<0 THEN 1 ELSE 0 END) down,"
"SUM(CASE WHEN change_pct>=9.8 THEN 1 ELSE 0 END) limit_up,"
"SUM(CASE WHEN change_pct<=-9.8 THEN 1 ELSE 0 END) limit_down,"
"ROUND(SUM(amount)/10000,2) amount_yi "
"FROM stock_daily WHERE date=?", (day,))
heat = query(
"SELECT s.industry, ROUND(AVG(d.change_pct),2) chg, COUNT(*) cnt "
"FROM stock_daily d JOIN stocks s ON s.code=d.code WHERE d.date=? "
"GROUP BY s.industry ORDER BY chg DESC", (day,))
gainers = query(
"SELECT s.name, s.code, s.industry, d.change_pct FROM stock_daily d "
"JOIN stocks s ON s.code=d.code WHERE d.date=? ORDER BY d.change_pct DESC LIMIT 8", (day,))
losers = query(
"SELECT s.name, s.code, s.industry, d.change_pct FROM stock_daily d "
"JOIN stocks s ON s.code=d.code WHERE d.date=? ORDER BY d.change_pct ASC LIMIT 8", (day,))
return {"date": day, "indexes": inds, "stat": stat, "heat": heat,
"gainers": gainers, "losers": losers}
def collect_news(since_date, limit=20):
rows = query(
"SELECT id,title,content,source,category,publish_date,sentiment,related_stocks FROM news "
"WHERE publish_date>=? ORDER BY publish_date DESC, id DESC LIMIT ?", (since_date, limit))
# 按重要度排序(类别权重 + 情感强度)
w = {"公司": 3, "业绩": 3, "机构观点": 2, "行业": 2, "市场": 1}
for n in rows:
n["_score"] = w.get(n["category"], 1) * 10 + abs(n["sentiment"]) * 5
rows.sort(key=lambda x: x["_score"], reverse=True)
return rows
def collect_positions():
rows = query(
"SELECT w.code, s.name, s.industry, s.market_cap, d.close, d.change_pct "
"FROM watchlist w JOIN stocks s ON s.code=w.code "
"LEFT JOIN stock_daily d ON d.code=s.code AND d.date=(SELECT MAX(date) FROM stock_daily) "
"ORDER BY w.added_at")
out = []
for r in rows:
sc = query_one(
"SELECT AVG(sentiment) s FROM news WHERE (related_stocks=? OR related_stocks LIKE ? OR related_stocks LIKE ?) "
"AND publish_date>=date('now','-7 day')", (r["code"], f"%,{r['code']}", f"{r['code']},%"))
out.append({**r, "news_score": round(sc["s"], 2) if sc and sc["s"] is not None else 0})
return out
def collect_targets():
tgts = query("SELECT id, type, code, name, keywords FROM watch_targets WHERE enabled=1")
out = []
for t in tgts:
if t["type"] == "stock" and t["code"]:
latest = query_one(
"SELECT meta, created_at FROM tracking_reports WHERE code=? ORDER BY id DESC LIMIT 1", (t["code"],))
else:
latest = query_one(
"SELECT meta, created_at FROM tracking_reports WHERE code=? ORDER BY id DESC LIMIT 1",
(f"CONCEPT:{t['name']}",))
m = json.loads(latest["meta"]) if latest else {}
out.append({"type": t["type"], "name": t["name"],
"impact": m.get("impact_score"), "change_kind": m.get("change_kind"),
"summary": m.get("summary", ""), "tracked_at": latest["created_at"] if latest else None})
return out
def collect_global():
r = query_one("SELECT date, data FROM global_markets ORDER BY date DESC LIMIT 1")
if not r:
return []
try:
data = json.loads(r["data"])
except Exception:
return []
items = []
for k in GLOBAL_ORDER:
if k in data:
items.append(data[k])
return items
# ===================================================================== 文本渲染
def fmt_market(mkt):
s = mkt["stat"] or {}
idx_txt = " ".join(f"{i['label']} {i['value']:.2f} ({i['chg']:+.2f}%)" for i in mkt["indexes"])
heat_txt = "、".join(f"{h['industry']}({h['chg']:+.2f}%)" for h in mkt["heat"][:6]) or "无"
g_txt = "、".join(f"{g['name']}({g['change_pct']:+.2f}%)" for g in mkt["gainers"][:5])
l_txt = "、".join(f"{g['name']}({g['change_pct']:+.2f}%)" for g in mkt["losers"][:5])
return {
"idx": idx_txt,
"breadth": (f"上涨 {s.get('up',0)} / 下跌 {s.get('down',0)} 家,"
f"涨停 {s.get('limit_up',0)} / 跌停 {s.get('limit_down',0)},"
f"两市成交 {s.get('amount_yi',0)} 亿"),
"heat": heat_txt,
"gainers": g_txt or "无",
"losers": l_txt or "无",
}
def fmt_news(news, top=8):
lines = []
for n in news[:top]:
tone = "利好" if n["sentiment"] > 0 else ("利空" if n["sentiment"] < 0 else "中性")
lines.append(f"- [{n['publish_date']}] {n['title']}({n['category']}·{tone}{n['sentiment']:+.2f}){n['content'][:60]}")
return "\n".join(lines) or "(暂无)"
def fmt_positions(pos):
if not pos:
return "(当前无持仓/自选股)"
return "\n".join(
f"- {p['name']}({p['code']}) {p['industry']} 收盘{p['close']} ({p['change_pct']:+.2f}%) 市值{p['market_cap']:.0f}亿 近7日消息面{p['news_score']:+.2f}"
for p in pos)
def fmt_targets(tgts):
if not tgts:
return "(当前无跟踪目标)"
return "\n".join(
f"- [{t['type']}] {t['name']} 影响度{t['impact'] or '--'}/100 {t['change_kind'] or ''} {t['summary'][:50]}"
for t in tgts)
def fmt_global(items):
return " ".join(f"{g.get('label','')} {g.get('value',0):.2f} ({g.get('chg',0):+.2f}%)" for g in items) or "(暂无)"
# ===================================================================== 生成报告
def _build_context(kind):
day = latest_trading_day()
mkt = collect_market(day)
fm = fmt_market(mkt)
if kind == "premarket":
news = collect_news(day, limit=24)
scope_txt = "昨日/最近交易日"
else:
news = collect_news(day, limit=24)
scope_txt = "今日"
pos = collect_positions()
tgts = collect_targets()
glob = collect_global()
ctx = {
"kind_name": KIND_META[kind]["name"],
"date": day,
"scope": scope_txt,
"mkt": mkt, "fm": fm,
"news": news, "news_txt": fmt_news(news, 10),
"pos": pos, "pos_txt": fmt_positions(pos),
"tgts": tgts, "tgts_txt": fmt_targets(tgts),
"global_txt": fmt_global(glob),
"global": glob,
}
return ctx
def _base_prompt(ctx, detailed):
d = ctx["date"]
title = KIND_META[ctx["kind_name"] if ctx["kind_name"] in KIND_META else "premarket"]["title"] if False else ""
kind = "盘前分析" if "盘前" in ctx["kind_name"] else "盘后总结"
return f"""你是资深A股市场分析师,请基于下方【数据】生成一份{kind}报告。
【报告日期】{d}
【指数】{ctx['fm']['idx']}
【涨跌结构】{ctx['fm']['breadth']}
【领涨行业】{ctx['fm']['heat']}
【领涨个股】{ctx['fm']['gainers']}
【领跌个股】{ctx['fm']['losers']}
【重点要闻】
{ctx['news_txt']}
【全球市场】
{ctx['global_txt']}
【持仓/自选股】
{ctx['pos_txt']}
【关注目标/主题】
{ctx['tgts_txt']}
"""
def _brief_prompt(ctx):
return _base_prompt(ctx, False) + """
【输出要求】输出一份精炼的盘前/盘后速览(约 200-300 字),Markdown 格式,包含:
1. 一句话大盘研判
2. 3-5 条关键要点(行情/消息/持仓/主题)
3. 今日关注提示
要求信息密集、数据准确,不要编造数据。"""
def _detail_prompt(ctx):
return _base_prompt(ctx, True) + """
【输出要求】输出一份完整的盘前/盘后分析报告(Markdown),结构如下:
## 一、市场概览(指数表现/涨跌结构/量能/领涨领跌板块个股解读)
## 二、消息面解析(分类解读重点要闻及影响:政策/行业/公司/机构观点)
## 三、全球市场联动(外围市场表现及对A股的传导)
## 四、持仓表现(逐只点评:涨跌、评分依据、近期消息面)
## 五、关注目标/主题(各主题/个股的最新动态与影响度解读)
## 六、操作策略与风险提示
数据须严格来自上文【数据】,可补充合理分析逻辑,不得编造数字。"""
def generate_reports(kind):
"""生成 (brief_html, detail_html)"""
ctx = _build_context(kind)
brief_md = ""
detail_md = ""
try:
brief_md = llm_chat([
{"role": "system", "content": "你是一名严谨专业的A股市场分析师。"},
{"role": "user", "content": _brief_prompt(ctx)},
]).strip()
except Exception as e:
log.warning("brief llm fail: %s", e)
try:
detail_md = llm_chat([
{"role": "system", "content": "你是一名严谨专业的A股市场分析师。"},
{"role": "user", "content": _detail_prompt(ctx)},
]).strip()
except Exception as e:
log.warning("detail llm fail: %s", e)
brief_html = _render_brief(ctx, brief_md)
detail_html = _render_detail(ctx, detail_md)
return brief_html, detail_html
# ===================================================================== 渲染
def _render_brief(ctx, brief_md):
kind = KIND_META[ctx["kind_name"] if ctx["kind_name"] in KIND_META else "premarket"]
rows = []
for i in ctx["mkt"]["indexes"]:
rows.append(f"=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
# 静默期判断:命中则不生成、不发送,仅记一条 quiet 日志
qc = quiet_config("report")
if qc["enabled"] and in_quiet_period(qc["ranges"]):
meta = KIND_META.get(kind, KIND_META["premarket"])
subject = f"[智能荐股] {meta['name']} {time.strftime('%Y-%m-%d')}"
execute("INSERT INTO report_log(kind, subject, brief_len, detail_len, status, message) "
"VALUES(?,?,0,0,'quiet',?)", (kind, subject, "静默期内已抑制发送"))
return {"ok": False, "quiet": True, "msg": "静默期内已抑制发送"}
mc = mail_config()
brief_html, detail_html = generate_reports(kind)
meta = KIND_META.get(kind, KIND_META["premarket"])
subject = f"[智能荐股] {meta['name']} {time.strftime('%Y-%m-%d')}"
detail_file = f"智能荐股_{meta['name']}_{time.strftime('%Y%m%d')}.html"
try:
send_email(subject, brief_html, cfg=mc,
attachments=[{"filename": detail_file, "content": detail_html.encode("utf-8")}])
execute("INSERT INTO report_log(kind, subject, brief_len, detail_len, status, message) "
"VALUES(?,?,?,?,'sent','附件: '||?)",
(kind, subject, len(brief_html), len(detail_html), detail_file))
return {"ok": True, "subject": subject, "detail_file": detail_file}
except Exception as e:
execute("INSERT INTO report_log(kind, subject, brief_len, detail_len, status, message) "
"VALUES(?,?,?,?,'failed',?)",
(kind, subject, len(brief_html), len(detail_html), str(e)))
return {"ok": False, "error": str(e)}
def report_log(limit=20):
return query("SELECT * FROM report_log ORDER BY id DESC LIMIT ?", (limit,))
if __name__ == "__main__":
import sys
kind = sys.argv[1] if len(sys.argv) > 1 else "premarket"
if kind not in ("premarket", "postmarket"):
kind = "premarket"
logging.basicConfig(level=logging.INFO)
r = send_daily_report(kind)
print(r)