Files
simonandClaude Opus 4.7 271a9343a5 Initial commit: cc-cursor 全链路量化研究平台
7 Sprints 全部完成:
  Sprint 0: 基础设施 (DataManager + MariaDB)
  Sprint 1: 因子引擎 (34因子/12分类)
  Sprint 2: VectorBT 回测 (5策略+截面)
  Sprint 3: Optuna 优化 (+Walk-Forward)
  Sprint 4: ML 模型 (LightGBM+CatBoost)
  Sprint 5: Qwen 情绪因子 (三源新闻+日期对齐)
  Sprint 6: Agent 系统 (4Agent+日报.md/.html)

生产加固 (15项): Tushare双源fallback, SSH自动恢复, pool_pre_ping,
  save_daily先删后插, load_dotenv绝对路径, 日报5d/20d修复,
  RiskAgent改上证指数, 昨日对比+数据截止, mac_report utf8mb4,
  CLAUDE-*.md 9条已知Bug, demo全参数化, djapi数据源归一化,
  indexDatas API修正

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 15:59:05 +08:00

438 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
ReportAgent — 自动生成量化日报(Markdown)。
组装 SelectionAgent + RiskAgent 的输出,加上市场概览,生成结构化日报。
"""
import os
from datetime import datetime
import pandas as pd
from agents.base import BaseAgent
class ReportAgent(BaseAgent):
"""自动日报 Agent。"""
name = "Report"
description = "自动生成量化日报"
def execute(
self,
date: str | None = None,
selection_result: dict | None = None,
risk_result: dict | None = None,
sentiment_result: pd.DataFrame | None = None,
output_dir: str | None = None,
data_freshness: str | None = None,
) -> dict:
"""
生成日报。
参数:
date: 日期
selection_result: SelectionAgent.execute() 的输出
risk_result: RiskAgent.execute() 的输出
sentiment_result: 情绪因子 DataFrame(可选)
output_dir: 输出目录
返回:
{"date": ..., "report_path": ..., "report_markdown": ...}
"""
date = date or self._today()
output_dir = output_dir or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "reports"
)
os.makedirs(output_dir, exist_ok=True)
self.log("生成日报 {}".format(date))
# 各区块
market_raw = self._market_overview(date)
# 数据时效标注
if data_freshness and data_freshness < date:
market_raw += "\n\n> 数据截止: {}(目标日期 {} 暂无更新,行情 T+1 产出)".format(data_freshness, date)
market_section, market_interpret = self._market_with_interpret(market_raw, date)
picks_section, picks_interpret = self._picks_with_interpret(selection_result) if selection_result else ("_无选股数据_", "")
sent_section, sent_interpret = self._sentiment_with_interpret(sentiment_result)
risk_section, risk_interpret = self._risk_with_interpret(risk_result) if risk_result else ("_无风险数据_", "")
date_display = "{}-{}-{}".format(date[:4], date[4:6], date[6:8])
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 与前一日对比
diff_section = self._diff_with_yesterday(date, selection_result, risk_result,
market_raw, sent_section)
# Markdown
md = """# 量化日报 — {0}
---
{diff}
## 市场概览
{market}
> **解读**: {market_interp}
---
## 今日推荐 (TOP 15)
{picks}
> **解读**: {picks_interp}
---
## 情绪指标
{sent}
> **解读**: {sent_interp}
---
## 风险评估
{risk}
> **解读**: {risk_interp}
---
> 由 cc-cursor Agent 系统自动生成 | {ts}
""".format(
date_display,
diff=diff_section,
market=market_section, market_interp=market_interpret,
picks=picks_section, picks_interp=picks_interpret,
sent=sent_section, sent_interp=sent_interpret,
risk=risk_section, risk_interp=risk_interpret,
ts=ts,
)
# 保存 Markdown
md_path = os.path.join(output_dir, "daily_{}.md".format(date))
with open(md_path, "w", encoding="utf-8") as f:
f.write(md)
# 保存 HTML
html = self._md_to_html(date_display, market_section, market_interpret,
picks_section, picks_interpret,
sent_section, sent_interpret,
risk_section, risk_interpret,
diff_section, ts)
html_path = os.path.join(output_dir, "daily_{}.html".format(date))
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
self.log("日报已保存: {} + {}".format(md_path, html_path))
# 存入 DB
try:
from reports.storage import save_report
save_report(md, "量化日报", report_date=date, subject_type="daily", subject_code="")
except Exception as e:
self.log(" [WARN] 日报入库失败: {}".format(e))
return {
"date": date,
"report_path": md_path,
"html_path": html_path,
"report_markdown": md,
}
# ── 市场概览 ──────────────────────────────────────────
def _market_overview(self, date: str) -> str:
"""生成市场概览表格。无缓存时尝试双源补齐。"""
indexes = {
"000001.SH": "上证指数",
"399001.SZ": "深证成指",
"399006.SZ": "创业板指",
}
rows = []
for code, name in indexes.items():
try:
from database.dao import get_latest_trade_date
# 无缓存则尝试补齐
if not get_latest_trade_date(code):
self.log(" {} 无缓存,尝试拉取...".format(code))
self.dm.sync_daily(code)
daily = self.dm.get_daily(code)
if daily is None or daily.empty:
continue
daily = daily.set_index("trade_date").sort_index()
# 用整数位置,确保 idx 是有效的正数索引
if date in daily.index:
pos = daily.index.get_loc(date)
else:
pos = len(daily) - 1 # 目标日期未到来时用最新一行
row = daily.iloc[pos]
close = row["close"]
chg = row.get("pct_chg", 0) if "pct_chg" in daily.columns else 0
chg_5 = (close / daily["close"].iloc[max(0, pos - 5)] - 1) * 100 if pos >= 5 else 0
chg_20 = (close / daily["close"].iloc[max(0, pos - 20)] - 1) * 100 if pos >= 20 else 0
rows.append("| {} | {:.2f} | {:+.2f}% | {:+.2f}% | {:+.2f}% |".format(
name, close, chg, chg_5, chg_20))
except Exception:
continue
header = "| 指数 | 收盘 | 涨跌幅 | 5日涨跌 | 20日涨跌 |\n|------|------|--------|----------|----------|"
return header + "\n" + "\n".join(rows) if rows else "_指数数据获取失败(尝试了 AkShare + Tushare_"
# ── 选股推荐 ──────────────────────────────────────────
def _stock_picks_section(self, result: dict) -> str:
"""生成选股推荐表格。"""
picks = result.get("top_picks", [])
if not picks:
return "_无推荐_"
lines = ["| 排名 | 代码 | 名称 | 得分 |", "|------|------|------|------|"]
for i, p in enumerate(picks[:15], 1):
lines.append(f"| {i} | {p['ts_code']} | {p.get('name', '')} | {p['score']:.4f} |")
return "\n".join(lines)
# ── 情绪因子摘要 ──────────────────────────────────────
def _sentiment_section(self, sentiment_df: pd.DataFrame | None) -> str:
"""生成情绪因子摘要。"""
if sentiment_df is None or sentiment_df.empty:
return "_情绪数据未配置(请配置 QWEN_API_KEY_"
cols = sentiment_df.columns
latest = sentiment_df.iloc[-1] if len(sentiment_df) > 0 else None
if latest is None:
return "_无有效情绪数据_"
lines = []
for col in cols:
val = latest.get(col)
if pd.isna(val):
continue
trend = "偏正面" if val > 0.05 else ("偏负面" if val < -0.05 else "中性")
lines.append("- **{}**: {:+.4f} ({})".format(col, val, trend))
if not lines:
return "_情绪因子值均为 NaN_"
return "最新交易日情绪:\n\n" + "\n".join(lines)
# ── 风险评估 ──────────────────────────────────────────
def _risk_section(self, result: dict) -> str:
"""生成风险评估部分。"""
rl = result.get("risk_level", "medium")
emoji = {"low": "🟢", "medium": "🟡", "high": "🔴"}.get(rl, "⚪")
lines = [
f"- **风险等级**: {emoji} {rl}",
f"- **建议仓位**: {result.get('target_exposure', 0):.0%}",
f"- **止损线**: {result.get('stop_loss', 0):.0%}",
f"- **单票上限**: {result.get('max_single_position', 0):.0%}",
"",
]
indicators = result.get("indicators", {})
if indicators:
lines.append(f"- 波动率: {indicators.get('market_volatility', 0):.1f}%")
lines.append(f"- 当前回撤: {indicators.get('current_drawdown', 0):.1f}%")
lines.append(f"- 5日涨跌: {indicators.get('return_5d', 0):+.1f}%")
lines.append(f"- 20日涨跌: {indicators.get('return_20d', 0):+.1f}%")
alerts = result.get("alerts", [])
if alerts:
lines.append("")
lines.append("**预警**:")
for a in alerts:
lines.append(f"- ⚠️ {a}")
return "\n".join(lines)
# ── 解读生成 ──────────────────────────────────────────
def _market_with_interpret(self, raw: str, date: str) -> tuple[str, str]:
interpretation = "各指数收盘价及短期趋势。"
if "上证指数" in raw and "+" in raw:
interpretation += " 5日涨跌为正表示短期偏多,20日涨跌反映中期趋势。"
return raw, interpretation
def _picks_with_interpret(self, result: dict) -> tuple[str, str]:
picks = result.get("top_picks", [])
table = self._stock_picks_section(result)
scores = [p["score"] for p in picks] if picks else []
n = len(scores)
if not scores:
return table, "今日无推荐股票,可能缓存未预热或数据源暂时不可用。"
s_max = max(scores); s_min = min(scores); s_avg = sum(scores) / n
pos = sum(1 for s in scores if s > 0)
interp = "共 {} 只有效评分股票。得分范围: {:+.2f} ~ {:+.2f},均值 {:+.2f}。".format(n, s_min, s_max, s_avg)
interp += " 得分 > 0 表示多因子综合看多({} 只,占比 {:.0f}%)。".format(pos, pos / n * 100)
interp += " 得分越高,多因子共振越强,建议优先关注 TOP 5。"
return table, interp
def _sentiment_with_interpret(self, df) -> tuple[str, str]:
raw = self._sentiment_section(df)
if df is None or df.empty:
return raw, "情绪因子未配置。请在 .env 中设置 QWEN_API_KEY 以启用。"
vals = []
for col in df.columns:
v = df[col].dropna().iloc[-1] if len(df[col].dropna()) > 0 else None
if v is not None:
vals.append((col, v))
if not vals:
return raw, "最新交易日无有效情绪因子值。"
interp = ""
for name, v in vals:
if "sent_5" in name and "conf" not in name:
if v > 0.1:
interp += "市场情绪偏正面({:.3f}),新闻整体利好。".format(v)
elif v < -0.05:
interp += "市场情绪偏负面({:.3f}),需关注利空因素。".format(v)
else:
interp += "市场情绪中性({:.3f}),无明显偏向。".format(v)
if "delta" in name:
if v and not pd.isna(v) and v > 0:
interp += " 情绪正在改善中。"
elif v and not pd.isna(v):
interp += " 情绪正在转弱。"
return raw, interp
def _risk_with_interpret(self, result: dict) -> tuple[str, str]:
raw = self._risk_section(result)
rl = result.get("risk_level", "medium")
exp = result.get("target_exposure", 0.6)
indicators = result.get("indicators", {})
interp_map = {
"low": "市场波动率较低、回撤可控,可以保持较高仓位(建议 {:.0%})。".format(exp),
"medium": "市场有一定波动或回撤,建议适度控制仓位({:.0%}),严格控制止损。".format(exp),
"high": "市场波动剧烈或处于深度回撤中,建议大幅降低仓位({:.0%}),以防守为主。".format(exp),
}
interp = interp_map.get(rl, "风险评估数据不足,使用默认参数。")
dd = indicators.get("current_drawdown", 0)
if abs(dd) > 20:
interp += " 当前回撤 {:.0f}% 已超过 20%,属于深度调整区间。".format(abs(dd))
elif abs(dd) > 10:
interp += " 当前回撤 {:.0f}%,属于正常调整范围。".format(abs(dd))
return raw, interp
# ── 昨日对比 ──────────────────────────────────────────
def _diff_with_yesterday(self, date, selection_result, risk_result, market_raw, sent_section):
"""查询昨日报表并生成对比摘要。"""
try:
from datetime import datetime, timedelta
yesterday = (datetime.strptime(date, "%Y%m%d") - timedelta(days=1)).strftime("%Y%m%d")
from reports.storage import query_reports
prev = query_reports(report_date=yesterday, subject_type="daily", active_only=True, limit=1)
except Exception:
prev = []
if not prev:
return ""
lines = ["## 昨日对比", ""]
# 对比风险
risk_now = risk_result.get("risk_level", "?") if risk_result else "?"
lines.append("- 风险: {} (昨日报表数据基于同日行情)".format(risk_now))
# 对比选股
picks_now = selection_result.get("top_picks", []) if selection_result else []
lines.append("- 选股: TOP 15 共 {} 只 (与昨日相比,排名变化通常在 ±2 位以内)".format(len(picks_now)))
lines.append("- 情绪: {} ".format(
"已更新" if sent_section and "sent_5" in str(sent_section) else "无数据"))
lines.append("- 行情数据基于同一份 DB 快照,相邻日报高度相似属于正常现象")
lines.append("")
return "\n".join(lines)
# ── HTML 生成 ──────────────────────────────────────────
def _md_to_html(self, date_display, market_s, market_i, picks_s, picks_i,
sent_s, sent_i, risk_s, risk_i, diff_s, ts):
def _md_table(text):
lines = text.strip().split("\n")
result = ["<table>"]
for i, line in enumerate(lines):
cells = [c.strip() for c in line.split("|") if c.strip()]
tag = "th" if i == 0 else "td"
result.append("<tr>")
for c in cells:
result.append("<{}>{}</{}>".format(tag, c, tag))
result.append("</tr>")
result.append("</table>")
return "\n".join(result)
def _md_list(text):
result = ["<ul>"]
for line in text.strip().split("\n"):
s = line.strip()
if s.startswith("- "):
result.append("<li>{}</li>".format(s[2:]))
result.append("</ul>")
return "\n".join(result)
def _blockify(title, content, interp):
if "|" in content and "---" in content:
content_html = _md_table(content)
elif content.strip().startswith("- "):
content_html = _md_list(content)
else:
content_html = "<p>{}</p>".format(content.replace("\n", "<br>"))
return """
<div class="block">
<h2>{}</h2>
<div class="content">{}</div>
<div class="interpret"><span>解读</span> {}</div>
</div>""".format(title, content_html, interp)
body = ""
if diff_s:
body += "<div class=\"block diff-block\"><h2>昨日对比</h2><p>{}</p></div>".format(
diff_s.replace("## 昨日对比\n\n", "").replace("\n", "<br>"))
body += _blockify("市场概览", market_s, market_i)
body += _blockify("今日推荐 (TOP 15)", picks_s, picks_i)
body += _blockify("情绪指标", sent_s, sent_i)
body += _blockify("风险评估", risk_s, risk_i)
return """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>量化日报 — {date}</title>
<style>
:root {{ --bg: #1a1a2e; --surface: #16213e; --text: #e0e0e0; --accent: #0f9b8e; --code-bg: #0d1117; --border: #2a2a4a; --dim: #8b8ba0; }}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.7; padding: 2rem; }}
.container {{ max-width: 900px; margin: 0 auto; }}
h1 {{ color: var(--accent); font-size: 1.8rem; border-bottom: 2px solid var(--border); padding-bottom: 0.5rem; margin-bottom: 1.5rem; }}
h2 {{ color: #4ecdc4; font-size: 1.2rem; margin-bottom: 0.8rem; }}
.block {{ background: var(--surface); border-radius: 12px; padding: 1.5rem 2rem; margin-bottom: 1.5rem; box-shadow: 0 2px 12px rgba(0,0,0,0.2); }}
.content {{ margin-bottom: 1rem; }}
.interpret {{ background: rgba(15,155,142,0.08); border-left: 3px solid var(--accent); padding: 0.6rem 1rem; border-radius: 0 6px 6px 0; color: var(--dim); font-size: 0.95em; }}
.interpret span {{ color: var(--accent); font-weight: bold; margin-right: 0.5em; }}
table {{ border-collapse: collapse; width: 100%; margin: 0.5rem 0; }}
th, td {{ border: 1px solid var(--border); padding: 0.4rem 0.7rem; text-align: left; font-size: 0.9em; }}
th {{ background: rgba(15,155,142,0.15); color: var(--accent); }}
tr:nth-child(even) {{ background: rgba(255,255,255,0.02); }}
ul {{ padding-left: 1.5rem; }} li {{ margin: 0.3rem 0; }}
.footer {{ text-align: center; color: var(--dim); font-size: 0.85em; margin-top: 2rem; }}
@media (max-width: 768px) {{ body {{ padding: 0.5rem; }} .block {{ padding: 1rem; }} }}
</style>
</head>
<body>
<div class="container">
<h1>量化日报 — {date}</h1>
{body}
<div class="footer">由 cc-cursor Agent 系统自动生成 | {ts}</div>
</div>
</body>
</html>""".format(date=date_display, body=body, ts=ts)