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,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