在开发量化模型时,我们经常试图从非传统因子中提取市场信号,而投资者情绪无疑是其中最具争议也最有潜力的方向之一。今天要介绍的研究正是 Anchada Charoenrook的经典论文《Does Sentiment Matter?》,这篇论文系统地研究了消费者情绪变化与市场超额收益之间的关系。
这篇论文首次发表于2006年,为了验证它今天还有没有用,我们根据论文里的核心观点写了一段Python,并放到2023-2025这段时间里跑一跑,看看最终的效果如何。我强烈建议你先看看论文的原文,PDF文档放在这里。
最后我们会给出完整的回测代码,希望对你有帮助。
更多精彩内容:
我们还是先回过头来讲讲这篇论文说了些啥。
论文核心观点
论文的核心问题是:投资者情绪是否会影响市场回报?
作者使用了密歇根大学的 Consumer Sentiment Index(CSI) 作为情绪代理变量,并定义了其年度变化率(即 CCSI = ΔCSI / CSIₜ₋₁)。研究发现:
- 当消费者情绪上升(投资者过于乐观)时,未来一个月及未来一年内的超额市场回报显著下降;
- 当情绪下降(投资者悲观)时,未来市场回报反而更高;
- 这种预测能力在统计与经济意义上都显著,并且独立于其他宏观控制变量(如股息率、利差、消费–财富比等);
- 情绪变化比传统的预测因子(如股息收益率)更能解释未来的市场走势。
换句话说,投资者的情绪变化本身就是一种系统性信号。
回测思路
我基于该论文搭建了一个情绪择时模型,并用 Python 实现了完整的回测逻辑。核心思路如下:
1. 信号构建
从 UMich 消费者信心指数中计算出同比变化率 CCSI,并滞后两个月(对应论文中的信息发布延迟处理)。
\begin{equation}
CCSI_t = \frac{CSI_t – CSI_{t-12}}{CSI_{t-12}}, \quad Signal = CCSI_{t-2}
\end{equation}
2. 回归参数引用
使用论文 Table 4 的月度回归系数:
\begin{equation}
\hat{r}_t = a + b \times CCSI_{t-1}, \quad a = 0.009, \ b = -0.049
\end{equation}
当预测的超额收益
3. 交易规则
调仓频率:每月第一个交易日
仓位分配:等权持仓
杠杆设定:0.9
资产标的:S&P500 ETF(SPY)作为市场代表。
这种逻辑非常直接,没有多因子,也没有复杂的优化。它几乎是一个纯学术信号的落地测试,目的是验证论文结果在现代市场数据中的延续性。
回测结果
| 回测区间 | 2023 – 2025 |
| 夏普比率 | 1.6 |
| 年化收益率 | 20.42% |
| 年化波动率 | 12.06% |
| 最大回撤 | 11.36% |
Python代码
Python
# sentiment_timing.py
# Infoway实时行情接口:www.infoway.io
# 原文链接:https://blog.infoway.io/python-sentiment-backtest/
import math
import datetime as dt
from collections import defaultdict
import numpy as np
import pandas as pd
import pwb_toolbox.backtesting as pwb_bt
import pwb_toolbox.datasets as pwb_ds
# --- 辅助函数:日期时间解析 ----------------------------------------
def _parse_datetimes_flexible(col: pd.Series) -> pd.Series:
"""
Return a UTC tz-aware datetime Series from a column that may be:
- epoch in s / ms / us / ns
- ISO 8601 strings
- mixed types
"""
s = pd.Series(col).copy()
# 优先尝试将数据解析为时间戳
num = pd.to_numeric(s, errors="coerce")
if num.notna().mean() > 0.5: # majority numeric? try epoch units
# 取一小部分样本,通过合理性判断推断时间戳单位
sample = num.dropna().astype("int64")
sample = sample.iloc[: min(1000, len(sample))]
def plausible(ts: pd.Series) -> bool:
yrs = ts.dt.year
# 若有 90% 以上的时间点落在现代区间(1950~2100 年),则认为单位有效
return (yrs.between(1950, 2100)).mean() >= 0.9
for unit in ("s", "ms", "us", "ns"):
try:
ts_try = pd.to_datetime(sample, unit=unit, utc=True)
if plausible(ts_try):
# 使用推断出的时间单位转换整列数据
return pd.to_datetime(num.astype("Int64"), unit=unit, utc=True)
except Exception:
pass
# 若失败则回退到毫秒级解析
return pd.to_datetime(num.astype("Int64"), unit="ms", utc=True, errors="coerce")
# 否则按字符串格式(如 ISO 格式)进行解析
return pd.to_datetime(s, utc=True, errors="coerce")
def _first_existing(df: pd.DataFrame, candidates) -> str:
for c in candidates:
if c in df.columns:
return c
raise KeyError(f"No datetime-like column found; tried: {candidates}")
def _try_load_umich_csi():
"""
尝试加载几种可能包含UMich CSI的数据集名称
若找到则返回一个包含 ['date_ms', 'CSI'] 的 pandas DataFrame,否则返回 None
"""
candidate_names = [
"Macro-UMich-ConsumerSentiment",
"Macro-UMich-CSI",
"Macro-UMich-ICS",
"Macro-Consumer-Sentiment-UMich",
]
for name in candidate_names:
try:
df = pwb_ds.load_dataset(name)
if df is None or len(df) == 0:
continue
df = df.copy()
# 通过启发式方法定位 CSI 指标列
col_candidates = [
c
for c in df.columns
if c.lower() in {"csi", "ics", "index", "value", "level"}
]
if not col_candidates:
continue
vcol = col_candidates[0]
# 统一字段格式
if "date" in df.columns:
date_col = "date"
elif "datetime" in df.columns:
date_col = "datetime"
else:
# 若没有日期类型的列则放弃本次尝试
continue
out = pd.DataFrame(
{
"date_ms": df[date_col].astype("int64"),
"CSI": pd.to_numeric(df[vcol], errors="coerce"),
}
).dropna()
return out
except Exception:
# 续尝试下一个候选数据集;库环境中无需记录日志
pass
return None
# --- 用作 _build_ccsi_series 的替代实现 -------------------------------
def _build_ccsi_series(source="auto", epsilon=0.1):
"""
构建消费者情绪的同比(月度)百分比变化(CCSI),并加入论文中的两个月滞后
返回:
ccsi_lag2 : pd.Series (索引为月末 UTC 时间,值为浮点型)
used_source : str ("umich" | "news")
"""
used_source = None
# 1) 如果可以获取 UMich CSI 数据,优先使用
umich = None
if source in ("auto", "umich"):
umich = _try_load_umich_csi()
if umich is not None:
used_source = "umich"
s = (
umich.assign(ts=pd.to_datetime(umich["date_ms"], unit="ms", utc=True))
.set_index("ts")
.sort_index()["CSI"]
.astype(float)
)
s_m = s.resample("M").last().dropna()
ccsi = (s_m - s_m.shift(12)) / s_m.shift(12)
else:
# 2) 否则回退到基于 All-Daily-News 数据构建的宏观情绪指标
used_source = "news"
news_df = pwb_ds.load_dataset("All-Daily-News")
df = news_df.copy()
# 寻找时间戳字段
dt_col = _first_existing(
df,
["datetime", "published_utc", "published_at", "time", "timestamp", "date"],
)
df["ts"] = _parse_datetimes_flexible(df[dt_col])
# 情绪值可能是字符串,需要转换为浮点型
if "sentiment" not in df.columns:
raise KeyError("All-Daily-News has no 'sentiment' column.")
df["sentiment"] = pd.to_numeric(df["sentiment"], errors="coerce")
df = df.dropna(subset=["ts", "sentiment"])
if df.empty:
raise ValueError("All-Daily-News provided no usable rows after parsing.")
# 按月计算平均情绪
m = df.set_index("ts")["sentiment"].resample("M").mean().dropna()
# 计算同比百分比变化,并加入一个微小的稳定项
ccsi = (m - m.shift(12)) / (m.shift(12).abs() + float(epsilon))
# 按论文设定,使用 t-2 的 CCSI 作为当月信号
ccsi_lag2 = ccsi.shift(2).dropna()
return ccsi_lag2, used_source
def _month_key(ts):
"""从pandas或datetime对象中返回 (year, month),(支持 UTC 或普通时间)"""
if isinstance(ts, (pd.Timestamp, np.datetime64)):
ts = pd.Timestamp(ts).to_pydatetime()
return ts.year, ts.month
class PaperStrategy(pwb_bt.BaseStrategy):
"""
基于论文《Does Sentiment Matter?》的情绪择时策略实现
核心逻辑(源自论文):
- 信号(Signal):CCSI_t 表示密歇根消费者信心指数(UMich CSI)的同比变化。
- 时序(Timing):将当月收益与两个月前的 CCSI 对齐(滞后两个月,以符合数据发布时间)。
- 频率(Cadence):按月执行(每月第一个交易日进行调仓)。
- 决策(Decision):使用论文中单变量(月度、加权平均)回归系数预测超额收益:
r_hat_t = a + b * CCSI_{t-1},其中 a=0.009,b=-0.049(见表4 Panel A)。
若预测超额收益 r_hat_t > 0 → 增加风险敞口(全仓做多,等权分配到所有标的); 否则 → 保持现金仓位。
说明:
- 论文研究的是市场整体的超额收益,这里将同样的情绪信号应用于所有输入的标的(通常是宽基指数 ETF,如 'SPY')。
- 年度版本(从7月到次年6月)的回归系数见表5 Panel A(a=0.079,b=-0.452),
代码中已保留该选项,但默认关闭,以保持与月度测试结果一致。
"""
params = (
("leverage", 0.9),
("total_days", 0), # 保留自模板代码
("use_one_year_horizon", False),
("threshold", 0.0), # 仅当预测值大于阈值时才开多仓
("ccsi_source", "auto"),
# 论文中的回归系数(按月度加权平均)—— 表4 Panel A:
("intercept_a", 0.009),
("slope_b", -0.049),
# 年度回归系数(加权平均)—— 表5 Panel A(仅在 use_one_year_horizon=True 时启用)
("annual_intercept_a", 0.079),
("annual_slope_b", -0.452),
# PWB 框架所需参数(请勿删除)
("indicator_cls", None),
("indicator_kwargs", {}),
)
def __init__(self):
super().__init__()
# 构建一个字典,将每个 (year, month) 映射到对应的 CCSI_{t-1},包含论文要求的两个月滞后
ccsi_series, used_source = _build_ccsi_series(self.p.ccsi_source)
self._ccsi_source = used_source
# 为 next() 方法提供快速查询:{(year, month): float}
self._ccsi_by_month = {_month_key(ix): val for ix, val in ccsi_series.items()}
# 记录调仓状态
self._last_rebalanced_key = None
# 便捷属性
self._nd = len(self.datas)
self._equal_weight = self.p.leverage / max(1, self._nd)
# 保存当前时间周期(如月度或年度)对应的回归系数
if self.p.use_one_year_horizon:
self._a = self.p.annual_intercept_a
self._b = self.p.annual_slope_b
else:
self._a = self.p.intercept_a
self._b = self.p.slope_b
# --- 工具方法 -----------------------------------------------------
def _is_first_trading_day_of_month(self):
d0 = self.datas[0]
if len(d0) == 0:
return False
today = d0.datetime.date(0)
if len(d0) <= 1:
# 回测中的第一根K线视为“当月首个交易日”
return True
yesterday = d0.datetime.date(-1)
return (today.month != yesterday.month) or (today.year != yesterday.year)
def _current_month_key(self):
d0 = self.datas[0]
today = d0.datetime.datetime(0) # 返回datetime
return today.year, today.month
def _forecast_excess_return(self, ccsi_value):
# 按论文中的单变量回归公式计算线性预测值
if ccsi_value is None or (
isinstance(ccsi_value, float) and not math.isfinite(ccsi_value)
):
return None
return float(self._a + self._b * ccsi_value)
# --- 交易逻辑 -------------------------------------------------------
def next(self):
super().next()
# 仅在每月第一个交易日进行决策
if not self._is_first_trading_day_of_month():
return
mkey = self._current_month_key()
if self._last_rebalanced_key == mkey:
return
# 获取与当前收益月份对应的滞后 CCSI(论文中为 t 使用 t-2)
ccsi = self._ccsi_by_month.get(mkey, None)
forecast = self._forecast_excess_return(ccsi)
# 若尚无可用的 CCSI 数据,则当月不操作
if forecast is None:
return
take_risk = forecast > self.p.threshold
# 目标仓位:若判断应承担风险则等权持仓,否则全仓持币
target_pct = self._equal_weight if take_risk else 0.0
for data in self.datas:
self.order_target_percent(data=data, target=target_pct)
self._last_rebalanced_key = mkey
# --- 策略运行模板 ------------------------------------------------------
def run_strategy():
"""
示例运行函数。
- 传入指数(如 'SPY');情绪择时信号会统一应用到所有标的上。
- 起始日期需保证有足够的数据预热,用于计算同比变化和两个月滞后。
"""
symbols = [
"SPY",
# 也可以添加其他ETF,信号会对所有标的等效生效
]
return pwb_bt.run_strategy(
strategy_cls=PaperStrategy,
strategy_kwargs={"leverage": 0.9}, # <-- 按模板保留此结构
indicator_cls=None, # PWB 框架保留项
indicator_kwargs={}, # PWB 框架保留项
symbols=symbols, # 使用日K数据
start_date="2023-06-01", # 与论文所用的长期历史数据保持一致
cash=100_000.0,
)
if __name__ == "__main__":
strategy = run_strategy()