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
View File
+45
View File
@@ -0,0 +1,45 @@
"""
Agent 抽象基类。
每个 Agent 负责一个独立任务,通过构造函数注入已有引擎,组合而非重建。
"""
from abc import ABC, abstractmethod
from datetime import datetime
class BaseAgent(ABC):
"""Agent 基类。"""
name: str = ""
description: str = ""
def __init__(self, **engines):
"""
注入已有基础设施。
支持的引擎:
dm: DataManager
fe: FactorEngine
bt: VectorBTEngine
opt: OptunaEngine
sent: SentimentEngine
ml_models: dict[str, BaseModel]
"""
self.dm = engines.get("dm")
self.fe = engines.get("fe")
self.bt = engines.get("bt")
self.opt = engines.get("opt")
self.sent = engines.get("sent")
self.ml_models = engines.get("ml_models", {})
@abstractmethod
def execute(self, **kwargs) -> dict:
"""执行 Agent 任务,返回结构化结果。"""
...
def log(self, msg: str):
print(f"[{self.name}] {msg}")
def _today(self) -> str:
return datetime.now().strftime("%Y%m%d")
+211
View File
@@ -0,0 +1,211 @@
"""
AgentOrchestrator — Agent 编排器。
管理所有 Agent 的生命周期、执行顺序、结果传递。
"""
from datetime import datetime
from agents.research_agent import ResearchAgent
from agents.selection_agent import SelectionAgent
from agents.risk_agent import RiskAgent
from agents.report_agent import ReportAgent
class AgentOrchestrator:
"""
Agent 编排器。
用法:
orch = AgentOrchestrator(dm=dm, fe=engine_fe, bt=engine_bt, ...)
orch.setup() # 注册所有 Agent
orch.run_daily() # 执行每日流程
"""
def __init__(self, **engines):
self.engines = engines
self.agents: dict = {}
self._last_results: dict = {}
def setup(self):
"""注册所有 Agent。"""
self.agents["research"] = ResearchAgent(**self.engines)
self.agents["selection"] = SelectionAgent(**self.engines)
self.agents["risk"] = RiskAgent(**self.engines)
self.agents["report"] = ReportAgent(**self.engines)
print(f"[Orchestrator] 已注册 {len(self.agents)} 个 Agent: {list(self.agents)}")
# ── 每日流程 ──────────────────────────────────────────
def run_daily(self, date: str | None = None) -> dict:
"""
每日任务流:
1. 同步数据
2. 风险评估
3. 股票打分
4. 生成日报
"""
date = date or datetime.now().strftime("%Y%m%d")
print(f"\n{'='*60}")
print(f"[Orchestrator] 每日流程 — {date}")
print(f"{'='*60}")
results = {"date": date}
# Step 1: 增量同步已缓存股票的最新行情
print("\n[Step 1/4] 同步行情...")
dm = self.engines.get("dm")
sent = self.engines.get("sent")
from database.dao import get_latest_trade_date
scope_stocks = []
if sent:
scope_stocks = sent.get_scope_stocks()
if not scope_stocks and dm:
scope_stocks = list(dm.get_stock_list().index[:100])
print(" 范围: {} 只股票".format(len(scope_stocks)))
# 分类:已缓存(增量更新),未缓存(统计跳过)
cached = [c for c in scope_stocks if get_latest_trade_date(c)]
uncached = len(scope_stocks) - len(cached)
print(" 已缓存: {} 只 (增量更新), 未缓存: {} 只 (跳过,需首次批量预热)".format(len(cached), uncached))
synced = 0
for i, ts_code in enumerate(cached):
try:
n = dm.sync_daily(ts_code)
synced += n
except Exception:
continue
if (i + 1) % 100 == 0:
print(" [同步] 进度: {}/{}".format(i + 1, len(cached)))
print(" 同步完成: {} 条新数据 (已缓存{}/全量{})".format(synced, len(cached), len(scope_stocks)))
if uncached > 0:
print(" 提示: {} 只股票未缓存,运行 'agent_cli.py warmup' 首次批量预热".format(uncached))
# Step 2: 风险评估
print("\n[Step 2/4] 风险评估...")
risk = self.agents["risk"].execute()
results["risk"] = risk
print(f" 风险: {risk['risk_level']}, 仓位: {risk['target_exposure']:.0%}")
# Step 3: 选股打分
print("\n[Step 3/4] 股票打分...")
selection = self.agents["selection"].execute(date=date, top_n=15)
results["selection"] = selection
top = selection.get("top_picks", [])
if top:
print(" Top 5: {}".format(", ".join(p['ts_code'] for p in top[:5])))
# Step 4: 情绪因子
print("\n[Step 4/5] 情绪因子...")
sentiment_df = None
sent_eng = self.engines.get("sent")
if sent_eng:
try:
# 用范围内第一只有缓存的股票计算情绪因子
ref_code = cached[0] if cached else "000001.SZ"
sentiment_df = sent_eng.compute(ref_code, max_news=30)
if sentiment_df is not None and not sentiment_df.empty:
valid = sentiment_df.dropna(how="all")
print(" {}: {} 个因子, {} 个有效交易日".format(ref_code, sentiment_df.shape[1], len(valid)))
else:
print(" (无有效情绪数据)")
except Exception as e:
print(" [SKIP] 情绪因子计算失败: {}".format(e))
else:
print(" (SentimentEngine 未配置)")
results["sentiment"] = sentiment_df
# Step 5: 生成日报
print("\n[Step 5/5] 生成日报...")
report = self.agents["report"].execute(
date=date,
selection_result=selection,
risk_result=risk,
sentiment_result=sentiment_df,
)
results["report"] = report
print(f" 日报: {report['report_path']}")
self._last_results = results
print(f"\n{'='*60}")
print(f"[Orchestrator] 每日流程完成")
print(f"{'='*60}\n")
return results
# ── 研究流程(每周一次) ────────────────────────────────
def run_research_cycle(self, ts_codes: list[str] | None = None) -> dict:
"""
研究周期:
1. 因子发现与评估
2. 更新 IC 权重
"""
print(f"\n{'='*60}")
print(f"[Orchestrator] 研究周期")
print(f"{'='*60}")
print("\n[Step 1/2] 因子发现...")
research = self.agents["research"].execute(ts_codes=ts_codes)
results = {"research": research}
top = research.get("top_factors", [])
if top:
print(f" Top 5 因子:")
for f in top[:5]:
print(f" {f['name']:20s} IC={f['ic_mean']:+.4f} ICIR={f['icir']:.3f}")
return results
# ── 便捷方法 ──────────────────────────────────────────
def picks(self, date: str | None = None, top_n: int = 15) -> dict:
"""快速选股。"""
return self.agents["selection"].execute(date=date, top_n=top_n)
def risk_check(self) -> dict:
"""快速风险评估。"""
return self.agents["risk"].execute()
def generate_report(self, date: str | None = None) -> dict:
"""快速生成日报。"""
date = date or datetime.now().strftime("%Y%m%d")
# 尝试拉取最新数据(Tushare 优先,几秒即可完成)
dm = self.engines.get("dm")
if dm:
try:
dm.sync_daily("000001.SZ")
except Exception:
pass
# 查 DB 最新交易日
data_freshness = None
try:
from database.dao import get_latest_trade_date
data_freshness = get_latest_trade_date("000001.SZ")
except Exception:
pass
sel = self.picks(date)
risk = self.risk_check()
# 尝试取情绪因子
sentiment_df = None
sent_eng = self.engines.get("sent")
if sent_eng:
try:
sentiment_df = sent_eng.compute("000001.SZ", max_news=30)
except Exception:
pass
return self.agents["report"].execute(
date=date, selection_result=sel, risk_result=risk,
sentiment_result=sentiment_df, data_freshness=data_freshness,
)
@property
def last_results(self) -> dict:
return self._last_results
+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)
+145
View File
@@ -0,0 +1,145 @@
"""
ResearchAgent — 因子发现与评估。
遍历注册因子,计算 IC/IC_IR/分层收益,输出 Top 因子。
"""
import numpy as np
import pandas as pd
from agents.base import BaseAgent
class ResearchAgent(BaseAgent):
"""因子发现 Agent。"""
name = "Research"
description = "因子发现与评估"
def execute(
self,
factor_names: list[str] | None = None,
ts_codes: list[str] | None = None,
lookahead: int = 5,
top_n: int = 10,
) -> dict:
"""
遍历因子,计算评估指标。
参数:
factor_names: 待评估因子列表,None=全部注册因子
ts_codes: 股票列表,None=DataManager 全部
lookahead: 前向收益窗口
top_n: 返回 Top N 因子
返回:
{"top_factors": [...], "all_results": DataFrame, "evaluated": int}
"""
from factors.registry import list_factors, get_factor
if factor_names is None:
# 只评估技术+基本面因子(情绪因子需要额外数据)
categories = ["动量", "RSI", "MACD", "量价", "布林", "ATR", "均线", "波动率", "换手率", "振幅", "基本面"]
factor_names = []
for cat in categories:
factor_names.extend(list_factors(cat))
if ts_codes is None:
stocks = self.dm.get_stock_list()
# 默认选 50 只代表股(前50只 + 避免API过载)
ts_codes = list(stocks.index[:50]) if self.dm else ["000001.SZ", "600519.SH"]
self.log(f"评估 {len(factor_names)} 个因子 × {len(ts_codes)} 只股票")
results = []
for fn in factor_names:
metrics = self._evaluate_factor(fn, ts_codes, lookahead)
if metrics:
results.append(metrics)
self.log(f" {fn}: IC={metrics.get('ic_mean', 0):.4f}" if metrics else f" {fn}: SKIP")
if not results:
return {"top_factors": [], "all_results": pd.DataFrame(), "evaluated": 0}
df = pd.DataFrame(results).sort_values("score", ascending=False)
top = df.head(top_n)
return {
"top_factors": top.to_dict("records"),
"all_results": df,
"evaluated": len(results),
}
def _evaluate_factor(
self, factor_name: str, ts_codes: list[str], lookahead: int
) -> dict | None:
"""对单个因子计算 IC/IC_IR。"""
from factors.registry import get_factor
from models.features import FeatureEngine
try:
factor = get_factor(factor_name)
except KeyError:
return None
fe = FeatureEngine(lookahead=lookahead, label_type="regression")
ics = []
long_rets = []
short_rets = []
success = 0
for ts_code in ts_codes:
try:
daily = self.dm.get_daily(ts_code)
if daily is None or daily.empty:
continue
daily = daily.set_index("trade_date").sort_index()
factor_df = self.fe.compute(ts_code, [factor])
if factor_df is None or factor_df.empty:
continue
X, y = fe.build(factor_df, daily, fit=True)
if X.empty or y.empty or factor_name not in X.columns:
continue
fv = X[factor_name].dropna()
yv = y.loc[fv.index]
if len(fv) < 30:
continue
ic = fv.corr(yv, method="spearman")
ics.append(ic)
# 分层收益
top_idx = fv.nlargest(int(len(fv) * 0.2)).index
bot_idx = fv.nsmallest(int(len(fv) * 0.2)).index
long_rets.append(yv.loc[top_idx.intersection(yv.index)].mean())
short_rets.append(yv.loc[bot_idx.intersection(yv.index)].mean())
success += 1
except Exception:
continue
if success < 3:
return None
ic_series = pd.Series(ics)
ic_mean = ic_series.mean()
ic_std = ic_series.std()
icir = ic_mean / ic_std if ic_std > 0 else 0
# 综合得分 = IC × IC_IR 加权
score = abs(ic_mean) * max(icir, 0)
return {
"name": factor_name,
"ic_mean": round(float(ic_mean), 4),
"ic_std": round(float(ic_std), 4),
"icir": round(float(icir), 4),
"long_ret": round(float(np.mean(long_rets)), 2) if long_rets else 0,
"short_ret": round(float(np.mean(short_rets)), 2) if short_rets else 0,
"stocks_evaluated": success,
"score": round(float(score), 4),
}
+113
View File
@@ -0,0 +1,113 @@
"""
RiskAgent — 仓位控制与风险预警。
根据市场波动率、回撤、相关性输出仓位建议和止损线。
"""
import numpy as np
import pandas as pd
from agents.base import BaseAgent
class RiskAgent(BaseAgent):
"""仓位控制 Agent。"""
name = "Risk"
description = "仓位控制与风险预警"
# 风险等级阈值
THRESHOLDS = {
"high": {"vol": 35, "dd": -15},
"medium": {"vol": 25, "dd": -8},
}
def execute(
self,
holdings: dict[str, float] | None = None,
market_index: str = "000001.SH", # 上证指数,非个股
ts_codes: list[str] | None = None,
) -> dict:
"""
评估市场风险并输出仓位建议。
参数:
holdings: {ts_code: 持仓比例}
market_index: 市场参考标的
ts_codes: 持仓股票列表
返回:
{"risk_level": str, "target_exposure": float, "indicators": dict, "alerts": list}
"""
holdings = holdings or {}
price = self.dm.get_daily(market_index)
if price is None or price.empty:
return self._default_result()
price = price.set_index("trade_date").sort_index()
close = price["close"]
daily_ret = close.pct_change().dropna()
# 市场波动率(年化)
market_vol = float(daily_ret.tail(252).std() * np.sqrt(252) * 100) if len(daily_ret) >= 20 else 30
# 当前回撤
peak = close.expanding().max()
current_dd = float((close.iloc[-1] / peak.iloc[-1] - 1) * 100)
# 风险等级
if market_vol > self.THRESHOLDS["high"]["vol"] or current_dd < self.THRESHOLDS["high"]["dd"]:
risk_level = "high"
target_exposure = 0.30
elif market_vol > self.THRESHOLDS["medium"]["vol"] or current_dd < self.THRESHOLDS["medium"]["dd"]:
risk_level = "medium"
target_exposure = 0.60
else:
risk_level = "low"
target_exposure = 0.85
# 最近 N 日涨跌
ret_5d = float(close.pct_change(5).iloc[-1] * 100) if len(close) >= 6 else 0
ret_20d = float(close.pct_change(20).iloc[-1] * 100) if len(close) >= 21 else 0
# 单票上限(风险越高越集中)
max_single = 0.15 if risk_level == "low" else (0.10 if risk_level == "medium" else 0.05)
stop_loss = -0.05 if risk_level == "low" else (-0.08 if risk_level == "medium" else -0.12)
# 持仓预警
alerts = []
if current_dd < -10:
alerts.append(f"市场回撤 {current_dd:.1f}%,考虑减仓")
if market_vol > 30:
alerts.append(f"市场波动率 {market_vol:.1f}%,处于高位")
for code, pct in holdings.items():
if pct > max_single:
alerts.append(f"{code} 仓位 {pct:.0%} 超过上限 {max_single:.0%}")
self.log(f"风险={risk_level} 波动={market_vol:.1f}% 回撤={current_dd:.1f}% 仓位→{target_exposure:.0%}")
return {
"risk_level": risk_level,
"target_exposure": round(target_exposure, 2),
"max_single_position": round(max_single, 2),
"stop_loss": round(stop_loss, 2),
"indicators": {
"market_volatility": round(market_vol, 1),
"current_drawdown": round(current_dd, 1),
"return_5d": round(ret_5d, 1),
"return_20d": round(ret_20d, 1),
"close": round(float(close.iloc[-1]), 2),
},
"alerts": alerts,
}
@staticmethod
def _default_result() -> dict:
return {
"risk_level": "medium",
"target_exposure": 0.60,
"max_single_position": 0.10,
"stop_loss": -0.08,
"indicators": {},
"alerts": ["数据不足,使用默认风险参数"],
}
+195
View File
@@ -0,0 +1,195 @@
"""
SelectionAgent — 多因子股票打分。
综合技术因子 + 基本面因子 + ML 预测值 → 股票综合得分排序。
"""
import numpy as np
import pandas as pd
from agents.base import BaseAgent
class SelectionAgent(BaseAgent):
"""股票打分 Agent。"""
name = "Selection"
description = "多因子股票打分与推荐"
def execute(
self,
date: str | None = None,
ts_codes: list[str] | None = None,
top_n: int = 15,
weighting: str = "equal",
) -> dict:
"""
对股票池打分排序。
参数:
date: 目标日期,None=最新
ts_codes: 股票池,None=指数成分股
top_n: 返回 Top N
weighting: 'equal' | 'ic_weighted' | 'ml'
返回:
{"date": ..., "top_picks": [...], "score_df": DataFrame}
"""
from factors.registry import get_factor, list_factors
# 确定股票池
if ts_codes is None:
if self.sent:
ts_codes = self.sent.get_scope_stocks()
else:
stocks = self.dm.get_stock_list()
ts_codes = list(stocks.index[:100])
if not ts_codes:
return {"date": date or self._today(), "top_picks": [], "score_df": pd.DataFrame()}
# 选择核心因子(覆盖多个维度,减少计算量)
core_factors = [
"momentum_20", "momentum_60",
"rsi_14", "volatility_20",
"vol_ratio_5", "ma_dev_20",
"turnover_5", "amplitude_5",
]
factor_objects = [get_factor(n) for n in core_factors]
self.log("打分 {} 只股票 (权重={})".format(len(ts_codes), weighting))
# 1. 筛选有 DB 缓存的股票(Orchestrator 已在 Step1 同步了全量范围)
from database.dao import get_latest_trade_date
available = []
for ts_code in sorted(ts_codes):
if get_latest_trade_date(ts_code):
available.append(ts_code)
self.log("缓存命中: {}/{} ({:.1f}%)".format(
len(available), len(ts_codes),
len(available) / len(ts_codes) * 100 if ts_codes else 0))
# 2. 打分(限制上限防止单次太慢)
score_limit = min(len(available), 300)
scores = {}
valid_count = 0
for i, ts_code in enumerate(available[:score_limit]):
try:
score = self._score_stock(ts_code, factor_objects, date, weighting)
if score is not None:
scores[ts_code] = score
valid_count += 1
except Exception:
continue
if (i + 1) % 50 == 0:
self.log(" 进度: {}/{}".format(i + 1, score_limit))
self.log("有效评分: {}/{}".format(len(scores), score_limit))
if not scores:
return {"date": date or self._today(), "top_picks": [], "score_df": pd.DataFrame()}
# 排序
sorted_scores = sorted(scores.items(), key=lambda x: x[1], reverse=True)
top = sorted_scores[:top_n]
# 取股票名称(ts_code 是 index
stock_list = self.dm.get_stock_list() if self.dm else pd.DataFrame()
name_map = {}
if not stock_list.empty and "name" in stock_list.columns:
name_map = dict(zip(stock_list.index, stock_list["name"]))
top_picks = []
for ts_code, score in top:
# 尝试多种格式匹配名称
code_no_suffix = ts_code.replace(".SZ", "").replace(".SH", "").replace(".BJ", "")
name = name_map.get(ts_code, name_map.get(code_no_suffix, ts_code))
top_picks.append({
"ts_code": ts_code,
"name": name,
"score": round(score, 4),
})
score_df = pd.DataFrame(
{"ts_code": list(scores.keys()), "score": list(scores.values())}
).sort_values("score", ascending=False).reset_index(drop=True)
return {
"date": date or self._today(),
"top_picks": top_picks,
"score_df": score_df,
"universe_size": len(ts_codes),
"valid_scores": valid_count,
}
def _filter_cached_stocks(self, ts_codes: list[str], limit: int = 100) -> list[str]:
"""筛选有 DB 日线缓存的股票,避免逐个调用 AkShare。"""
from database.dao import get_latest_trade_date
cached = []
for ts_code in ts_codes[:limit]:
latest = get_latest_trade_date(ts_code)
if latest:
cached.append(ts_code)
return cached
def _score_stock(
self,
ts_code: str,
factors: list,
date: str | None,
weighting: str,
) -> float | None:
"""对单只股票打分。"""
daily = self.dm.get_daily(ts_code)
if daily is None or daily.empty:
return None
daily = daily.set_index("trade_date").sort_index()
factor_df = self.fe.compute(ts_code, factors)
if factor_df is None or factor_df.empty:
return None
if date and date in factor_df.index:
row = factor_df.loc[date]
else:
row = factor_df.iloc[-1] # 最新一天
if row.isna().all():
return None
if weighting == "ml" and self.ml_models:
return self._score_ml(ts_code, factor_df, date)
# 等权打分:标准化因子值后求和
row_clean = row.dropna()
if len(row_clean) < 3:
return None
# z-score 标准化
z = (row_clean - factor_df[row_clean.index].mean()) / factor_df[row_clean.index].std().replace(0, 1)
return float(z.mean())
def _score_ml(self, ts_code: str, factor_df: pd.DataFrame, date: str | None) -> float | None:
"""ML 模型打分。"""
from models.features import FeatureEngine
fe = FeatureEngine(lookahead=5)
daily = self.dm.get_daily(ts_code)
if daily is None or daily.empty:
return None
daily = daily.set_index("trade_date")
try:
X, _ = fe.build(factor_df, daily, fit=False)
if X.empty:
return None
if date and date in X.index:
X = X.loc[[date]]
else:
X = X.iloc[[-1]]
model = self.ml_models.get("lightgbm") or list(self.ml_models.values())[0]
pred = model.predict(X)
return float(pred.iloc[0]) if len(pred) > 0 else None
except Exception:
return None