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:
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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": ["数据不足,使用默认风险参数"],
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user