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>
This commit is contained in:
2026-06-07 15:59:05 +08:00
co-authored by Claude Opus 4.7
commit 271a9343a5
293 changed files with 59598 additions and 0 deletions
+437
View File
@@ -0,0 +1,437 @@
"""
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)