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>
169 lines
5.3 KiB
Python
169 lines
5.3 KiB
Python
"""
|
||
情绪因子。
|
||
|
||
将 Qwen 输出的情绪分数转换为量化因子值。
|
||
"""
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
from factors.base import BaseFactor
|
||
|
||
|
||
class NewsSentimentFactor(BaseFactor):
|
||
"""
|
||
新闻情绪因子。
|
||
|
||
将多条新闻的情绪分数按时间加权聚合到每个交易日。
|
||
|
||
参数:
|
||
window: 滚动窗口(交易日)
|
||
decay: 指数衰减系数(越大衰减越快),0 表示等权
|
||
sentiment_df: 情绪分析结果 DataFrame
|
||
(date, sentiment_score, confidence, title, content)
|
||
"""
|
||
|
||
category = "sentiment"
|
||
|
||
def __init__(self, window: int = 5, decay: float = 0.3, sentiment_df: pd.DataFrame | None = None):
|
||
self.window = window
|
||
self.decay = decay
|
||
self.sentiment_df = sentiment_df
|
||
self.name = f"news_sent_{window}"
|
||
|
||
def calculate(self, df: pd.DataFrame) -> pd.Series:
|
||
if self.sentiment_df is None or self.sentiment_df.empty:
|
||
return pd.Series(float("nan"), index=df.index, name=self.name)
|
||
|
||
return _aggregate_sentiment(
|
||
df, self.sentiment_df, self.window, self.decay
|
||
)
|
||
|
||
def get_required_columns(self) -> list[str]:
|
||
return []
|
||
|
||
|
||
class SentimentMomentumFactor(BaseFactor):
|
||
"""
|
||
情绪动量因子。
|
||
|
||
当前情绪 - N 日前情绪,衡量情绪变化方向。
|
||
"""
|
||
|
||
category = "sentiment"
|
||
|
||
def __init__(self, period: int = 5, sentiment_df: pd.DataFrame | None = None):
|
||
self.period = period
|
||
self.sentiment_df = sentiment_df
|
||
self.name = f"sent_delta_{period}"
|
||
|
||
def calculate(self, df: pd.DataFrame) -> pd.Series:
|
||
base = NewsSentimentFactor(window=1, sentiment_df=self.sentiment_df).calculate(df)
|
||
return base.diff(self.period)
|
||
|
||
def get_required_columns(self) -> list[str]:
|
||
return []
|
||
|
||
|
||
class SentimentConfidenceFactor(BaseFactor):
|
||
"""
|
||
情绪置信度因子。
|
||
|
||
新闻情绪分析的置信度越高,因子绝对值越大(方向同 sentiment_score)。
|
||
sentiment_score × confidence → 高置信利好=正值大,高置信利空=负值大。
|
||
"""
|
||
|
||
category = "sentiment"
|
||
|
||
def __init__(self, window: int = 5, sentiment_df: pd.DataFrame | None = None):
|
||
self.window = window
|
||
self.sentiment_df = sentiment_df
|
||
self.name = f"news_conf_{window}"
|
||
|
||
def calculate(self, df: pd.DataFrame) -> pd.Series:
|
||
if self.sentiment_df is None or self.sentiment_df.empty:
|
||
return pd.Series(float("nan"), index=df.index, name=self.name)
|
||
|
||
sdf = self.sentiment_df.copy()
|
||
# 设置加权分数
|
||
if "confidence" in sdf.columns and "sentiment_score" in sdf.columns:
|
||
sdf["weighted_score"] = sdf["sentiment_score"] * sdf["confidence"]
|
||
else:
|
||
return pd.Series(float("nan"), index=df.index, name=self.name)
|
||
|
||
return _aggregate_sentiment(df, sdf, self.window, decay=0.3, score_col="weighted_score")
|
||
|
||
def get_required_columns(self) -> list[str]:
|
||
return []
|
||
|
||
|
||
# ── 情绪聚合工具函数 ──────────────────────────────────────
|
||
|
||
def _aggregate_sentiment(
|
||
daily_df: pd.DataFrame,
|
||
sentiment_df: pd.DataFrame,
|
||
window: int,
|
||
decay: float,
|
||
score_col: str = "sentiment_score",
|
||
) -> pd.Series:
|
||
"""
|
||
将情绪分数按时间加权聚合到交易日。
|
||
|
||
逻辑:
|
||
1. 对每个交易日 t,找到 [t - window + 1, t] 范围内的所有新闻
|
||
2. 按 time_decay = exp(-decay * days_from_t) 加权
|
||
3. 按 confidence(如有)加权
|
||
4. 返回加权平均情绪分数
|
||
|
||
参数:
|
||
daily_df: 日线 DataFrame(提供 index 和日期对齐)
|
||
sentiment_df: 情绪 DataFrame(date 列 + score_col)
|
||
window: 窗口大小
|
||
decay: 衰减系数
|
||
score_col: 情绪分数列名
|
||
"""
|
||
if sentiment_df.empty:
|
||
return pd.Series(float("nan"), index=daily_df.index, name=score_col)
|
||
|
||
# 统一日期格式
|
||
sdf = sentiment_df.copy()
|
||
sdf["date"] = pd.to_datetime(sdf["date"], format="%Y%m%d", errors="coerce")
|
||
sdf = sdf.dropna(subset=["date"])
|
||
sdf = sdf.sort_values("date")
|
||
|
||
daily_idx = pd.to_datetime(daily_df.index, format="%Y%m%d", errors="coerce")
|
||
if daily_idx.isna().all():
|
||
daily_idx = pd.to_datetime(daily_df.index)
|
||
|
||
result = pd.Series(float("nan"), index=daily_df.index)
|
||
|
||
# 对 news 日期建立搜索索引
|
||
news_dates = sdf["date"].values
|
||
|
||
for i, dt in enumerate(daily_idx):
|
||
if pd.isna(dt):
|
||
continue
|
||
# 窗口起始
|
||
window_start = dt - pd.Timedelta(days=window * 2) # 宽窗覆盖非交易日
|
||
|
||
mask = (news_dates >= window_start) & (news_dates <= dt)
|
||
candidates = sdf[mask]
|
||
|
||
if candidates.empty:
|
||
continue
|
||
|
||
# 时间衰减权重
|
||
days_diff = (dt - candidates["date"]).dt.days
|
||
time_weights = np.exp(-decay * days_diff)
|
||
|
||
# 置信度权重(如有)
|
||
conf_weights = candidates.get("confidence", pd.Series(1.0, index=candidates.index)).fillna(0.5)
|
||
scores = candidates[score_col].fillna(0.0)
|
||
|
||
total_weight = (time_weights * conf_weights).sum()
|
||
if total_weight > 0:
|
||
result.iloc[i] = (scores * time_weights * conf_weights).sum() / total_weight
|
||
|
||
result.name = score_col
|
||
return result.astype("float64")
|