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>
196 lines
6.5 KiB
Python
196 lines
6.5 KiB
Python
"""
|
||
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
|