4. 单因子回测框架搭建:回测框架设计、信号生成与交易逻辑、绩效评估指标
好了,到了真正动手的环节了。
前面我们讲了数据清洗、讲了因子计算,但那些都是「食材」。今天我们要搭一个「厨房」——也就是单因子回测框架。说白了,就是把你算好的因子值,扔进一个模拟的交易环境里,看看它到底能不能赚钱。
我个人习惯把回测框架拆成三个模块:信号生成、交易逻辑、绩效评估。这三个模块串起来,就是一个完整的回测流水线。
核心思路: 因子值 → 交易信号 → 模拟成交 → 绩效指标。每一步都有坑,我们一个一个来填。
4.1 回测框架的整体设计
先画个图,让你对整个框架有个直观印象。我习惯用一张流程图来梳理逻辑,这样写代码的时候不容易乱。
这张图我画了好几个版本,最后觉得这个最清晰。你写代码的时候,也可以先画个类似的图,把每个模块的输入输出理清楚。
4.2 信号生成:从因子值到交易指令
信号生成是整个框架的「大脑」。因子值本身只是一堆数字,你得告诉程序:什么情况下买入?什么情况下卖出?
最常见的做法是分位数分组法。比如,每个月末,把所有股票的因子值从大到小排序,分成10组。做多第1组(因子值最大的),做空第10组(因子值最小的)。
嗯,这里要注意:分组数不是固定的。我见过有人用5组,也有人用20组。分组越细,信号越敏感,但换手率也越高。我个人习惯用10组,这是一个比较平衡的选择。
避坑指南: 我曾经在信号生成时犯过一个低级错误——用了未来数据。具体来说,我用的是当天的因子值来生成当天的信号,但实际上因子值在收盘后才能算出来。正确的做法是:用T-1日的因子值,生成T日的信号。
除了分组法,还有阈值法。比如,因子值大于2就买入,小于-2就卖出。这种方法更直接,但阈值怎么定?我建议你先看看因子值的分布,别拍脑袋定。
# 信号生成示例代码(伪代码)
def generate_signals(factor_df, method='quantile', n_groups=10):
"""
factor_df: 包含日期、股票代码、因子值的DataFrame
"""
if method == 'quantile':
# 每月分组
factor_df['group'] = factor_df.groupby('date')['factor'].transform(
lambda x: pd.qcut(x, n_groups, labels=False)
)
# 做多第0组(最大),做空第n_groups-1组(最小)
factor_df['signal'] = 0
factor_df.loc[factor_df['group'] == 0, 'signal'] = 1 # 买入
factor_df.loc[factor_df['group'] == n_groups-1, 'signal'] = -1 # 卖出
return factor_df
4.3 交易逻辑:模拟真实成交
信号生成之后,就是交易逻辑了。这一步最容易出问题,因为真实交易和理想情况差太多了。
你想想看,信号说「买入」,但你真的能买到吗?不一定。比如涨停了买不进去,或者流动性太差,一买就把价格拉上去了。所以,交易逻辑里必须考虑滑点和交易成本。
我一般这样处理:
- 滑点:假设每次交易都有0.1%的滑点成本。这个数字是我根据历史数据统计出来的,不同市场不一样。
- 手续费:A股一般是万分之三,加上印花税(卖出时千分之一)。
- 涨跌停限制:如果股票涨停,当天不能买入;跌停不能卖出。
警告: 千万别忽略交易成本!我见过一个因子,不考虑成本时年化收益20%,考虑成本后直接变成-5%。这种因子就是「纸面富贵」,实盘会亏死你。
交易逻辑的代码实现,我习惯用一个循环来模拟每一天的操作:
# 交易逻辑伪代码
def run_backtest(signals, price_df, initial_capital=1e6):
capital = initial_capital
position = 0 # 当前持仓
trade_log = []
for date in sorted(signals['date'].unique()):
today_signals = signals[signals['date'] == date]
today_prices = price_df.loc[date]
# 先处理卖出信号
sell_stocks = today_signals[today_signals['signal'] == -1]
for stock in sell_stocks['stock']:
if position > 0:
# 考虑滑点和手续费
sell_price = today_prices[stock] * (1 - 0.001) # 0.1%滑点
capital += position * sell_price * (1 - 0.0003) # 手续费
position = 0
trade_log.append((date, stock, 'sell', sell_price))
# 再处理买入信号
buy_stocks = today_signals[today_signals['signal'] == 1]
for stock in buy_stocks['stock']:
if capital > 0:
buy_price = today_prices[stock] * (1 + 0.001) # 0.1%滑点
position = capital / buy_price * (1 - 0.0003) # 手续费
capital = 0
trade_log.append((date, stock, 'buy', buy_price))
return capital, trade_log
这段代码很简化,但核心逻辑都在了。实际项目中,你还要处理多只股票同时持仓的情况,那就需要引入仓位管理了。
4.4 绩效评估指标:用数字说话
回测跑完了,拿到了一串净值曲线。然后呢?你得用指标来量化它的表现。我最常用的三个指标是:年化收益率、夏普比率、最大回撤。
4.4.1 年化收益率
这个最简单,就是把总收益率换算成年化形式。假设回测了3年,总收益是50%,那年化就是:
年化收益率 = (1 + 0.5)^(1/3) - 1 ≈ 14.5%
4.4.2 夏普比率
夏普比率衡量的是「每承担一单位风险,能获得多少超额收益」。公式是:
夏普比率 = (策略年化收益率 - 无风险利率) / 年化波动率
一般来说,夏普比率大于1算不错,大于2算优秀。我见过最夸张的一个因子,夏普比率做到了3.5,但后来发现是数据有误……
个人经验: 夏普比率高不一定代表因子好。如果回测时间短(比如只有1年),或者市场处于单边牛市,夏普比率会虚高。我一般要求回测至少3年,并且包含一段熊市。
4.4.3 最大回撤
最大回撤就是「从最高点跌到最低点,最多亏了多少」。这个指标很直观,也很残酷。一个年化收益30%的策略,如果最大回撤是40%,你敢用吗?反正我不敢。
我给自己定了个规矩:最大回撤不能超过年化收益的一半。比如年化20%,最大回撤最好控制在10%以内。
下面是一个绩效评估的汇总表,方便你对照:
| 指标名称 | 计算公式 | 优秀标准 | 注意事项 |
|---|---|---|---|
| 年化收益率 | (1+总收益)^(1/年数)-1 | > 15% | 需考虑无风险利率 |
| 夏普比率 | (策略收益 - 无风险利率) / 波动率 | > 1.5 | 回测期需包含熊市 |
| 最大回撤 | max(峰值 - 谷值) / 峰值 | < 10% | 越低越好 |
| 胜率 | 盈利交易次数 / 总交易次数 | > 50% | 需结合盈亏比看 |
| 盈亏比 | 平均盈利 / 平均亏损 | > 2 | 越高越好 |
最后,我想说一句:回测只是起点,不是终点。一个因子在回测里表现再好,也不代表实盘能赚钱。但如果你连回测这关都过不了,那实盘就更没戏了。
好了,这一章的内容就到这。框架搭好了,下一步就是往里面填因子,看看哪些是真金,哪些是沙子。
公众号:蓝海资料掘金营,微信deep3321