Appearance
因子回测与评估:验证因子是否真的能赚钱
你在因子构建阶段发现了一个看起来不错的因子——IC均值0.06,ICIR 0.8,分组回测呈完美单调。这是不是意味着可以直接上实盘了?远远不够。IC分析和分组回测只是因子研究的初步验证。一个因子从"统计上有效"到"实盘中能赚钱",中间还有很长的路要走。
因子回测的核心目标是:在尽可能贴近真实交易的条件下,评估因子驱动策略的风险收益特征。本文将深入讨论回测中的关键环节:换仓频率、多空组合构建、因子衰减以及常见偏差。
回测框架的基本结构
一个完整的因子回测框架包含以下核心模块:
股票池筛选 → 因子计算与预处理 → 定期换仓 → 组合构建 → 业绩归因每一期(每个换仓日)重复以上流程,将各期的收益率拼接起来,就得到了策略的完整回测曲线。
基础回测框架
python
import pandas as pd
import numpy as np
from typing import Tuple
class FactorBacktest:
"""因子回测框架"""
def __init__(self, df: pd.DataFrame, factor_col: str,
n_groups: int = 5, holding_days: int = 20,
cost_rate: float = 0.003):
"""
Args:
df: 包含 date, code, close, factor_col 的DataFrame
factor_col: 因子列名
n_groups: 分组数量
holding_days: 持有期(交易日)
cost_rate: 单边交易成本(佣金+滑点+印花税)
"""
self.df = df.copy()
self.factor_col = factor_col
self.n_groups = n_groups
self.holding_days = holding_days
self.cost_rate = cost_rate
def run(self) -> Tuple[pd.DataFrame, dict]:
"""运行回测"""
dates = sorted(self.df['date'].unique())
rebalance_dates = dates[::self.holding_days]
portfolio_records = []
prev_holdings = set()
for i in range(len(rebalance_dates) - 1):
rb_date = rebalance_dates[i]
next_rb = rebalance_dates[i + 1]
# 换仓日选股
daily = self.df[self.df['date'] == rb_date].dropna(
subset=[self.factor_col]
)
if len(daily) < self.n_groups * 20:
continue
daily['group'] = pd.qcut(
daily[self.factor_col], self.n_groups,
labels=False, duplicates='drop'
) + 1
# 取多头组(因子值最高组)
top_stocks = set(daily[daily['group'] == self.n_groups]['code'])
# 计算换手率与交易成本
turnover = len(top_stocks.symmetric_difference(prev_holdings)) / (
max(len(top_stocks), len(prev_holdings)) or 1
)
trade_cost = turnover * self.cost_rate
# 计算持有期收益
hold_data = self.df[
(self.df['date'] > rb_date) & (self.df['date'] <= next_rb)
]
hold_data = hold_data[hold_data['code'].isin(top_stocks)]
daily_returns = hold_data.groupby('date').apply(
lambda x: x['close'].pct_change().mean()
).dropna()
# 扣除交易成本
if len(daily_returns) > 0:
period_return = (1 + daily_returns).prod() - 1 - trade_cost
else:
period_return = -trade_cost
portfolio_records.append({
'date': next_rb,
'return': period_return,
'turnover': turnover,
'n_stocks': len(top_stocks),
})
prev_holdings = top_stocks
result = pd.DataFrame(portfolio_records)
metrics = self._calc_metrics(result)
return result, metrics
def _calc_metrics(self, result: pd.DataFrame) -> dict:
"""计算策略指标"""
if len(result) == 0:
return {}
returns = result['return']
cum_nav = (1 + returns).cumprod()
annual_return = (1 + returns).prod() ** (252 / max(len(returns) * self.holding_days, 1)) - 1
annual_vol = returns.std() * np.sqrt(252 / self.holding_days)
sharpe = annual_return / annual_vol if annual_vol != 0 else 0
max_drawdown = (cum_nav / cum_nav.cummax() - 1).min()
avg_turnover = result['turnover'].mean()
return {
'累计收益': f'{(cum_nav.iloc[-1] - 1):.2%}',
'年化收益率': f'{annual_return:.2%}',
'年化波动率': f'{annual_vol:.2%}',
'夏普比率': f'{sharpe:.2f}',
'最大回撤': f'{max_drawdown:.2%}',
'平均换手率': f'{avg_turnover:.2%}',
'交易期数': len(result),
}换仓频率的影响
换仓频率是因子回测中最敏感的参数之一。它直接影响策略的收益、交易成本和可执行性。
不同换仓频率的特点
日频换仓(每个交易日调仓)
- 因子信号利用最充分,理论收益最高
- 交易成本极高,实际收益可能远低于理论值
- 对流动性要求极高,大资金无法执行
- 实际操作中几乎不可行
周频换仓(每5个交易日调仓)
- 兼顾信号时效性和交易成本
- 适合短期动量、情绪类因子
- 换手率仍然较高,需仔细评估交易成本影响
月频换仓(每20个交易日调仓)
- 最常用的换仓频率
- 交易成本可控
- 适合大多数因子(价值、质量、规模等)
- 在信号时效性和成本之间取得了良好平衡
季频换仓(每60个交易日调仓)
- 交易成本极低
- 适合变化缓慢的因子(如质量因子、财务指标类因子)
- 缺点是信号更新较慢,可能错过市场拐点
换仓频率对收益的实际影响
python
def compare_holding_periods(df: pd.DataFrame, factor_col: str,
periods: list = [5, 10, 20, 40, 60]) -> pd.DataFrame:
"""比较不同换仓频率的策略表现"""
results = []
for period in periods:
bt = FactorBacktest(df, factor_col, holding_days=period)
_, metrics = bt.run()
metrics['换仓频率'] = f'{period}天'
results.append(metrics)
return pd.DataFrame(results).set_index('换仓频率')一个关键发现:在很多因子研究中,日频换仓的理论夏普比率可能是月频换仓的2-3倍,但扣除交易成本后,月频换仓的实际夏普比率反而更高。这说明:
理论上的最优频率不等于实际中的最优频率。 交易成本是连接理论和现实的桥梁。
交易成本的合理估计
在A股市场,交易成本包括以下几部分:
| 成本项 | 买入 | 卖出 | 备注 |
|---|---|---|---|
| 佣金 | 0.025% | 0.025% | 万2.5,可谈 |
| 印花税 | 0 | 0.05% | 2023年减半后为万5 |
| 滑点 | 0.05%-0.1% | 0.05%-0.1% | 视流动性而定 |
| 合计(单边) | 约0.08% | 约0.13% |
因此在回测中,单边交易成本建议设为0.15%-0.2%(偏保守),双边总成本约0.3%-0.4%。如果研究的是小盘股策略,滑点可能更大,单边成本应提高到0.3%以上。
python
def estimate_transaction_cost(market_cap: float, daily_volume: float,
trade_amount: float) -> float:
"""根据市值和流动性估计交易成本"""
# 基础成本
base_cost = 0.0015 # 单边万15
# 流动性冲击成本
participation_rate = trade_amount / daily_volume if daily_volume > 0 else 1
impact_cost = 0.1 * participation_rate ** 0.5 # 平方根模型
# 小盘股额外成本
if market_cap < 5e9: # 50亿以下
impact_cost *= 2
return base_cost + impact_cost多空组合构建
多空组合是评估因子收益最标准的方式。它同时买入因子值最高的股票组合(多头)和卖空因子值最低的股票组合(空头),两个组合的收益差就是因子的纯收益。
多头组合
在A股市场,由于融券限制较多,实际操作中以多头策略为主:
python
def build_long_portfolio(df: pd.DataFrame, factor_col: str,
rb_date: str, n_stocks: int = 50,
weighting: str = 'equal') -> dict:
"""构建多头组合"""
daily = df[df['date'] == rb_date].dropna(subset=[factor_col])
daily = daily.sort_values(factor_col, ascending=False)
selected = daily.head(n_stocks)
if weighting == 'equal':
weights = pd.Series(1.0 / n_stocks, index=selected['code'])
elif weighting == 'market_cap':
total_cap = selected['market_cap'].sum()
weights = selected['market_cap'] / total_cap
weights.index = selected['code']
elif weighting == 'factor_weighted':
factor_values = selected[factor_col].clip(lower=0)
total_factor = factor_values.sum()
if total_factor > 0:
weights = factor_values / total_factor
else:
weights = pd.Series(1.0 / n_stocks, index=selected['code'])
weights.index = selected['code']
return {
'stocks': selected['code'].tolist(),
'weights': weights.to_dict(),
}多空组合
虽然A股做空困难,但多空组合在学术研究和因子评估中仍然重要——它能剥离掉市场Beta的影响,展示因子的纯Alpha:
python
def build_long_short_portfolio(df: pd.DataFrame, factor_col: str,
rb_date: str, n_groups: int = 5) -> dict:
"""构建多空组合"""
daily = df[df['date'] == rb_date].dropna(subset=[factor_col])
daily['group'] = pd.qcut(daily[factor_col], n_groups,
labels=False, duplicates='drop') + 1
long_stocks = daily[daily['group'] == n_groups]['code'].tolist()
short_stocks = daily[daily['group'] == 1]['code'].tolist()
n_long = len(long_stocks)
n_short = len(short_stocks)
long_weights = {s: 1.0 / n_long for s in long_stocks}
short_weights = {s: -1.0 / n_short for s in short_stocks}
return {
'long': long_stocks,
'short': short_stocks,
'long_weights': long_weights,
'short_weights': short_weights,
}加权方式的影响
| 加权方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 等权 | 简单、分散化好 | 可能超配小盘股 | 通用,基准方法 |
| 市值加权 | 贴近实际可投资组合 | 被大盘股主导 | 大资金投资 |
| 因子加权 | 强化因子暴露 | 可能集中度过高 | 因子增强策略 |
对于因子评估而言,等权是最标准的方法,因为它不受市值分布的影响,能够更纯粹地反映因子的选股能力。
因子衰减
因子衰减是指因子信号对未来收益的预测能力随时间推移而减弱的现象。理解因子衰减对于选择合适的换仓频率和持有期至关重要。
衰减曲线的绘制
python
def calc_factor_decay(df: pd.DataFrame, factor_col: str,
max_forward_days: int = 120,
step: int = 5) -> pd.DataFrame:
"""计算因子衰减曲线"""
df = df.sort_values(['code', 'date'])
decay_results = []
for forward_days in range(step, max_forward_days + 1, step):
# 计算forward_days天后的收益率
df['forward_ret'] = df.groupby('code')['close'].transform(
lambda x: x.shift(-forward_days) / x - 1
)
# 逐截面计算IC
dates = sorted(df['date'].unique())
ic_list = []
for date in dates[:-forward_days]: # 确保有足够的前瞻数据
daily = df[df['date'] == date].dropna(
subset=[factor_col, 'forward_ret']
)
if len(daily) < 50:
continue
ic = daily[factor_col].corr(daily['forward_ret'], method='spearman')
ic_list.append(ic)
decay_results.append({
'forward_days': forward_days,
'mean_ic': np.mean(ic_list) if ic_list else 0,
})
return pd.DataFrame(decay_results)不同因子的衰减特征
- 价值因子:衰减较慢,IC在60-120天后仍然显著。适合月频或季频换仓。
- 动量因子:中期(3-12个月)衰减较慢,但短期(1个月内)可能出现反转。适合月频换仓。
- 情绪/技术因子:衰减很快,IC在5-10天后急剧下降。适合周频甚至日频换仓,但交易成本是主要制约。
- 质量因子:衰减最慢,IC在季度频率上仍然显著。适合季频换仓。
衰减分析的实际意义
如果因子的IC在5天后衰减到接近零,那么月频换仓的策略实际上只有第一个周是有信号优势的,其余三周都在"裸跑"。这种情况下,要么缩短换仓周期(但要考虑交易成本),要么这个因子可能不适合单独使用,而应该作为辅助因子。
常见偏差
因子回测中的偏差是导致"回测很美好、实盘很骨感"的主要原因。以下是几个最关键、最容易被忽视的偏差。
前视偏差(Look-ahead Bias)
前视偏差是指在回测中使用了当时不可能知道的信息。这是回测中最致命的偏差。
典型场景:
- 财务数据未及时对齐:使用了一季报的数据,但回测日期在一季报披露之前。正确做法是只使用已披露的最新财报数据。
- 使用了当日收盘价交易:如果策略信号基于当日收盘价计算,那么实际买入只能在次日开盘或更晚。应该用次日开盘价或VWAP作为买入价。
- 复权价使用不当:使用前复权价格时,历史价格会因为未来的除权事件而改变,导致因子值计算错误。应该使用不复权价格配合分红除权事件单独处理。
- 成份股调整:使用指数成份股作为股票池时,如果用了未来日期的成份股列表,就会引入前视偏差。
python
def avoid_lookahead(df: pd.DataFrame) -> pd.DataFrame:
"""避免前视偏差的数据处理"""
# 使用次日开盘价作为执行价格
df = df.sort_values(['code', 'date'])
df['execution_price'] = df.groupby('code')['open'].shift(-1)
# 只使用已披露的财务数据
# (见因子构建一文中的时点对齐方法)
# 使用当日开始已知的成份股列表
# 不要使用当期期末的成份股列表
return df存活者偏差(Survivorship Bias)
存活者偏差是指数据中只包含了当前仍在交易的股票,遗漏了已退市或被并购的股票。这会导致回测结果系统性偏乐观。
影响程度: 在A股市场,2018-2020年间有大量股票退市,如果数据不包含这些退市股票,价值因子和质量因子的回测表现会被高估,因为这些退市股票往往因子值较差、收益表现差。
python
def check_survivorship_bias(df: pd.DataFrame) -> dict:
"""检查存活者偏差"""
total_codes = df['code'].nunique()
# 检查每个日期点的股票数量
stocks_per_date = df.groupby('date')['code'].nunique()
report = {
'总股票数': total_codes,
'起始日股票数': stocks_per_date.iloc[0],
'结束日股票数': stocks_per_date.iloc[-1],
'期间最低股票数': stocks_per_date.min(),
'是否可能存在存活者偏差': stocks_per_date.min() < stocks_per_date.iloc[0] * 0.8,
}
return report应对方法: 使用包含退市股票的完整历史数据库。这也是为什么专业的因子研究需要付费数据源——免费数据源通常不包含退市股票。
数据窥探偏差(Data Snooping)
数据窥探偏差是指反复在同一数据集上测试不同的因子参数或因子定义,然后选择表现最好的结果。这种"选择性报告"会导致回测结果严重高估因子的真实效果。
表现形式:
- 反复调整回望周期,直到找到表现最好的参数
- 在同一个数据集上测试了几十个因子,只报告表现最好的那一个
- 不断修改因子定义的细节(如去极值的阈值),直到回测结果"好看"
应对方法:
- 样本外测试:将数据分为训练集和测试集,在训练集上开发因子,在测试集上验证。最严格的做法是"纸上交易"——因子开发完成后,跟踪其未来实时表现。
- 参数稳健性检验:如果因子在参数小幅变化时表现稳定,说明不是数据窥探的结果。如果最优参数从60天改为55天或65天,策略表现就大幅下降,那很可能是过拟合。
- Bonferroni校正:如果测试了N个因子或N组参数,显著性阈值应从0.05调整为0.05/N。
python
def robustness_test(df: pd.DataFrame, factor_col: str,
param_range: range) -> pd.DataFrame:
"""参数稳健性检验"""
results = []
for param in param_range:
# 用不同参数计算因子并测试
df_test = df.copy()
df_test[f'factor_{param}'] = df_test.groupby('code')['close'].transform(
lambda x: x / x.shift(param) - 1
)
# 计算IC
df_test = df_test.sort_values(['code', 'date'])
df_test['forward_ret'] = df_test.groupby('code')['close'].transform(
lambda x: x.shift(-20) / x - 1
)
ic_series = []
for date in df_test['date'].unique():
daily = df_test[df_test['date'] == date].dropna(
subset=[f'factor_{param}', 'forward_ret']
)
if len(daily) > 50:
ic = daily[f'factor_{param}'].corr(
daily['forward_ret'], method='spearman'
)
ic_series.append(ic)
results.append({
'param': param,
'mean_ic': np.mean(ic_series),
'ic_std': np.std(ic_series),
})
return pd.DataFrame(results)其他常见偏差
过度拟合(Overfitting)
与数据窥探密切相关。当模型参数过多、因子组合过于复杂时,模型完美拟合了历史数据中的噪声,但对未来没有预测能力。判断方法:如果样本外表现比样本内差50%以上,很可能存在过拟合。
未来函数(Future Function)
回测代码中隐含使用了未来数据。比如使用全量数据的标准化(而非逐截面标准化)、使用全局均值填充缺失值等。正确做法是在每个时间截面上独立进行所有数据处理。
交易约束忽略
忽略涨跌停无法成交、停牌无法交易、ST涨跌幅限制5%、新股首日涨跌停限制不同等A股特有约束。这些约束在实际交易中会显著影响策略执行。
回测结果的正确解读
即使避免了上述所有偏差,回测结果仍然需要谨慎解读。
关键评估指标
python
def comprehensive_evaluation(returns: pd.Series, benchmark_returns: pd.Series = None) -> dict:
"""全面的策略评估"""
cum_nav = (1 + returns).cumprod()
annual_return = (1 + returns).prod() ** (252 / len(returns)) - 1
annual_vol = returns.std() * np.sqrt(252)
sharpe = annual_return / annual_vol if annual_vol != 0 else 0
# 最大回撤及回撤持续期
running_max = cum_nav.cummax()
drawdown = cum_nav / running_max - 1
max_dd = drawdown.min()
# 回撤修复时间
dd_end = drawdown.idxmin()
dd_start = cum_nav[:dd_end].idxmax()
recovery = cum_nav[dd_end:].ge(running_max[dd_start])
recovery_date = recovery.idxmax() if recovery.any() else None
metrics = {
'累计收益': f'{(cum_nav.iloc[-1] / cum_nav.iloc[0] - 1):.2%}',
'年化收益': f'{annual_return:.2%}',
'年化波动': f'{annual_vol:.2%}',
'夏普比率': f'{sharpe:.2f}',
'最大回撤': f'{max_dd:.2%}',
'收益回撤比': f'{annual_return / abs(max_dd):.2f}' if max_dd != 0 else 'N/A',
'最长回撤持续期': f'{(recovery_date - dd_end).days}天' if recovery_date else '未修复',
'胜率(日)': f'{(returns > 0).mean():.2%}',
'正收益月占比': f'{(returns.resample("M").sum() > 0).mean():.2%}',
}
# 相对基准的指标
if benchmark_returns is not None:
excess = returns - benchmark_returns
tracking_error = excess.std() * np.sqrt(252)
information_ratio = excess.mean() / tracking_error if tracking_error != 0 else 0
metrics['信息比率'] = f'{information_ratio:.2f}'
metrics['跟踪误差'] = f'{tracking_error:.2%}'
return metrics评判标准
| 指标 | 及格 | 良好 | 优秀 |
|---|---|---|---|
| 夏普比率 | >0.5 | >1.0 | >1.5 |
| 最大回撤 | <30% | <20% | <15% |
| 年化收益/最大回撤 | >0.5 | >1.0 | >2.0 |
| 正收益月占比 | >55% | >60% | >65% |
重要提醒:以上标准仅供参考。实际评判时需要考虑策略类型(低频策略的夏普比率天然低于高频策略)、市场环境(牛市中所有策略都好看)和资金约束。
回测结果的"可信度检查"
在相信回测结果之前,问自己几个问题:
- 如果这个因子真的这么好,为什么别人没有发现?(如果逻辑太简单但收益太好,很可能有偏差)
- 策略的换手率是多少?扣除真实的交易成本后还赚钱吗?
- 策略在最近一年的表现如何?如果近期表现明显差于历史平均,可能因子已经失效。
- 参数稍微改变一下(回望周期从60天改为50天或70天),策略表现还稳定吗?
- 如果在2020-2024这段A股特殊时期单独测试,结果还成立吗?
总结
因子回测是连接因子研究和实盘投资的桥梁。一个好的回测不仅要展示策略能赚多少钱,更要揭示策略在各种市场环境下的风险特征。换仓频率的选择需要在信号时效性和交易成本之间取得平衡;多空组合是评估因子纯收益的标准方法;因子衰减决定了策略的最优持有期;而对前视偏差、存活者偏差和数据窥探偏差的警惕,是确保回测结果可靠的基本前提。
最后一个忠告:永远不要完全信任回测结果。回测是必要的研究工具,但历史不会简单重复。在实盘之前,用小资金进行纸上交易验证,是降低风险的最好方法。
本文仅为教学目的,不构成任何投资建议。回测结果不代表未来收益,实盘交易需考虑流动性、市场冲击、资金约束等现实因素。