上一篇: 第5篇:运筹优化
一、写在前面
前面几篇搭建了完整的预测和优化流程:
- 第1篇:数据获取和清洗
- 第2篇:特征工程
- 第3篇:预测模型
- 第4篇:运筹优化
但有个关键问题还没回答:这套策略真的能赚钱吗?
这就需要回测系统。用历史数据模拟实际交易,看看策略在过去的表现如何。如果在过去都不行,未来大概率也不行。
二、为什么需要回测
刚开始做这个项目的时候,我训练完模型看到方向准确率接近50%,和随机猜差不多,就觉得模型没什么用。
但后来发现,方向准确率不是衡量模型好坏的正确指标。
2.1 几个现实问题
问题1:过拟合
模型在训练集上表现很好,测试集上也还可以,但换一批数据就不行了。回测能验证模型在不同时间段的稳定性。
问题2:交易成本
每次买卖都有手续费。频繁交易的话,手续费会吃掉大部分利润。
A股的实际成本:
- 买入:佣金约0.03%
- 卖出:佣金0.03% + 印花税0.1%
- 单次完整交易(买入+卖出):约0.16%
举个例子:
- 预测准确率60%,每次赚1%
- 但每次交易成本0.16%
- 实际收益:1% - 0.16% = 0.84%
- 如果交易100次,成本就是16%
问题3:滑点
理论上你想在10元买,但实际可能买在10.05元。市场不会完全按你的计划执行。
问题4:未来函数(最致命)
如果模型偷偷用了未来的信息,准确率会虚高,但实际完全没用。
比如:
# 错误示例
df['tomorrow_return'] = df['close'].shift(-1) / df['close'] - 1
df['signal'] = np.where(df['tomorrow_return'] > 0, 1, -1)
这代码看起来没问题,但实际上用了"明天的收益率"来决定"今天的信号"。这就是未来函数。
回测就是为了发现这些问题。
三、回测的基本逻辑
回测的核心思路很简单:
for 每个交易日:
1. 根据当天的数据,预测明天涨跌
2. 根据预测结果,决定买入/卖出
3. 用明天的实际价格,计算盈亏
4. 记录当天的净值
关键是:只能用"过去"的信息做决策,不能偷看"未来"。
3.1 时间线示例
2022-01-04:
- 能看到: 2021-12-31及之前的数据
- 做决策: 根据历史数据预测,生成交易信号
- 不能看: 2022-01-05的价格(那是未来)
2022-01-05:
- 执行交易: 用昨天(1月4日)的信号决定今天持仓
- 结算: 用今天收盘价 vs 昨天收盘价计算盈亏
- 对应代码: actual_return = close_today / close_yesterday - 1
这个时间线看起来简单,但实际编码时很容易出错。最常见的错误就是不小心用了未来数据。
四、回测实现
我实现的回测策略是:每个交易日,用LightGBM预测所有股票的次日收益率,选出预测排名前20%的股票做多,其余空仓。
4.1 策略逻辑
# 第1步:对每日预测值做截面排名(0~1)
df['pred_rank'] = df.groupby('date')['predicted_return'].rank(pct=True)
# 第2步:top20%信号=1(做多),其余=0(空仓)
df['signal'] = np.where(df['pred_rank'] >= 0.8, 1, 0)
# 第3步:按股票分组shift,避免跨股票错位
df['signal_lag'] = df.groupby('symbol')['signal'].shift(1)
# 第4步:策略收益 = 昨日信号 × 今日实际收益
df['strategy_return'] = df['signal_lag'] * df['actual_return']
为什么用截面排名而不是直接用预测值?
模型输出的预测值是经过截面标准化训练的(每日排名百分位),直接用0作为阈值不合适。用截面排名可以保证每天都选固定比例的股票,不受市场整体涨跌影响。
4.2 完整代码
def backtest_long_only(df, top_pct=0.2):
"""
只做多策略:每日选预测排名前top_pct的股票持有
"""
df = df.sort_values(['symbol', 'date']).reset_index(drop=True)
# 截面排名
df['pred_rank'] = df.groupby('date')['predicted_return'].rank(pct=True)
df['signal'] = np.where(df['pred_rank'] >= (1 - top_pct), 1, 0)
# 按股票分组shift,避免跨股票错位
df['signal_lag'] = df.groupby('symbol')['signal'].shift(1)
# 每日聚合:只统计持仓股票的平均收益
def daily_agg(g):
market = g['actual_return'].mean()
held = g[g['signal_lag'] == 1]['actual_return']
strategy = held.mean() if len(held) > 0 else 0.0
return pd.Series({'actual_return': market, 'strategy_return': strategy})
daily = df.dropna(subset=['signal_lag']).groupby('date').apply(daily_agg)
# 累计净值
daily['market_equity'] = (1 + daily['actual_return']).cumprod() * 100000
daily['strategy_equity'] = (1 + daily['strategy_return']).cumprod() * 100000
return daily
4.3 代码逐行解释
第1步:截面排名
df['pred_rank'] = df.groupby('date')['predicted_return'].rank(pct=True)
每个交易日内,对所有股票的预测值做排名,转成0~1的百分位。排名0.9意味着这只股票的预测值比90%的股票高。
第2步:生成信号
df['signal'] = np.where(df['pred_rank'] >= 0.8, 1, 0)
排名前20%(pred_rank >= 0.8)信号=1,其余=0。每天大约持有股票池的20%。
第3步:时间对齐
df['signal_lag'] = df.groupby('symbol')['signal'].shift(1)
用昨天的信号对应今天的收益,避免未来函数。按股票分组确保不跨股票错位。
第4步:每日收益聚合
held = g[g['signal_lag'] == 1]['actual_return']
strategy = held.mean() if len(held) > 0 else 0.0
只统计实际持仓股票的平均收益,空仓日收益为0。
4.4 运行结果
初始资金10万,测试期2022-07-08 ~ 2024-12-24(599个交易日,约2.5年):
【绩效对比】
市场表现(买入持有):
total_return: 23.68%
annual_return: 9.35%
volatility: 20.32%
sharpe_ratio: 0.3127
max_drawdown: -23.04%
策略表现(LightGBM + 每日Top20%做多):
total_return: 95.82%
annual_return: 32.67%
volatility: 22.88%
sharpe_ratio: 1.2970
max_drawdown: -25.42%
win_rate: 51.75%
分析:
- 年化收益:32.67% vs 9.35%,提升了23.32个百分点
- 夏普比率:1.2970 vs 0.3127,提升315%
- 最大回撤:-25.42% vs -23.04%,略高于市场(集中持仓的代价)
策略大幅跑赢市场。最大回撤略高于买入持有,是因为每天只持有预测排名前20%的股票,集中度更高。
五、关键指标详解
回测有几个核心指标,每个都很重要。
5.1 总收益率
total_return = (final_equity / initial_capital) - 1
比如:
- 初始10万
- 最终11.8万
- 总收益率 = (118000 / 100000) - 1 = 18%
这个指标最直观,但有个问题:没考虑时间。赚18%,用1年和用3年,完全不一样。
5.2 年化收益率
days = 365 # 或根据实际天数
annual_return = (1 + total_return) ** (365 / days) - 1
把收益率标准化到"每年"。
比如:
- 3年赚了18%
- 年化收益率 = (1.18)^(1/3) - 1 = 5.67%
这样不同时间长度的策略可以对比了。
5.3 波动率(Volatility)
volatility = daily_returns.std() * sqrt(252)
衡量收益的波动程度,也就是风险。
- 波动率高:收益不稳定,有时赚很多有时亏很多
- 波动率低:收益稳定
sqrt(252)** 是把日波动率转成年化波动率(一年约252个交易日)。**
5.4 夏普比率(Sharpe Ratio)
这是最重要的综合指标。
sharpe_ratio = (annual_return - risk_free_rate) / volatility
含义:每承担1单位风险,能获得多少超额收益。
比如:
- 年化收益12%
- 无风险利率3%(国债)
- 波动率19%
- 夏普比率 = (0.12 - 0.03) / 0.19 = 0.47
解读标准:
- 夏普比率 < 0:亏钱,还不如买国债
- 0 - 0.5:勉强可以
- 0.5 - 1:不错
- 1 - 2:很好
- 2:优秀(专业机构水平)
我的策略夏普比率1.30,达到了"很好"的水平。
5.5 最大回撤(Max Drawdown)
# 计算累计最高净值
cummax = equity.cummax()
# 回撤 = (当前净值 - 历史最高) / 历史最高
drawdown = (equity - cummax) / cummax
# 最大回撤
max_drawdown = drawdown.min()
衡量"从最高点到最低点,最多亏了多少"。
比如:
- 净值从12万跌到10.5万
- 回撤 = (10.5 - 12) / 12 = -12.5%
为什么重要?
这是投资者最关心的风险指标。如果最大回撤30%,意味着你的10万可能会变成7万。很多人在回撤20%的时候就受不了了。
专业机构的最大回撤一般控制在15%以内。
5.6 胜率(Win Rate)
win_rate = (daily_returns > 0).sum() / len(daily_returns)
注意这里算的是日胜率(有多少天整体是正收益),不是交易胜率(每笔买卖赚钱的比例)。两者含义不同,日胜率更容易计算,但交易胜率更能反映单次决策的质量。
比如:
- 总共252个交易日
- 赚钱145天
- 胜率 = 145 / 252 = 57.5%
注意:胜率高不代表赚钱多。
可能出现:
- 胜率60%,但赚的时候赚1%,亏的时候亏3% → 总体亏损
- 胜率40%,但赚的时候赚5%,亏的时候亏1% → 总体盈利
所以要结合赔率一起看。
六、可视化分析
数字说明不了全部,图表更直观。
6.1 净值曲线
plt.figure(figsize=(12, 6))
plt.subplot(2, 1, 1)
plt.plot(daily_returns.index, daily_returns['market_equity'], label='买入持有')
plt.plot(daily_returns.index, daily_returns['strategy_equity'], label='预测策略')
plt.title('净值曲线对比')
plt.xlabel('日期')
plt.ylabel('净值')
plt.legend()
plt.grid(True)
净值曲线能看出:
- 策略是否跑赢市场
- 波动是否比市场小
- 有没有持续性
理想的曲线:稳步向上,波动小,回撤少。
6.2 回撤曲线
plt.subplot(2, 1, 2)
# 计算回撤
market_dd = (market_equity - market_equity.cummax()) / market_equity.cummax()
strategy_dd = (strategy_equity - strategy_equity.cummax()) / strategy_equity.cummax()
plt.plot(daily_returns.index, market_dd * 100, label='买入持有回撤')
plt.plot(daily_returns.index, strategy_dd * 100, label='预测策略回撤')
plt.title('回撤对比')
plt.ylabel('回撤 (%)')
plt.legend()
回撤曲线能看出:
- 最大回撤发生在什么时候
- 回撤持续多久
- 是否能快速恢复
七、避免未来函数
这是回测最容易犯的错误,也是最致命的。
7.1 什么是未来函数
未来函数就是:用了未来的信息做决策。
比如你在2022年1月4日做决策,但偷偷看了1月5日的价格。回测结果会很好看,但实际交易时完全没用。
7.2 常见错误
错误1:不shift
# 错误!
df['strategy_return'] = df['signal'] * df['actual_return']
这样是用"今天的信号"对应"今天的收益",等于偷看了今天的价格才做决策。
正确做法:
# 正确
df['strategy_return'] = df['signal'].shift(1) * df['actual_return']
用"昨天的信号"对应"今天的收益"。
错误2:特征用了未来数据
# 错误!
df['ma_20'] = df['close'].rolling(20).mean()
如果没有按股票分组,可能会用到其他股票未来的数据。
正确做法:
# 正确
df['ma_20'] = df.groupby('symbol')['close'].transform(
lambda x: x.rolling(20).mean()
)
错误3:目标变量构建错误
# 错误!用的是过去的收益
df['target'] = df['close'].pct_change(1)
# 正确!用shift(-1)取未来的收益
df['target'] = df.groupby('symbol')['close'].shift(-1) / df['close'] - 1
注意:目标变量可以用未来数据(因为是用来训练的),但特征和信号不能用。
八、交易成本的影响
简化版回测没考虑成本,实际交易有很多成本。
8.1 主要成本
1. 手续费
买入:成交金额 × 0.03%(券商佣金)
卖出:成交金额 × 0.03%(券商佣金)+ 0.1%(印花税)
比如买卖一次1万元的股票:
- 买入:10000 × 0.03% = 3元
- 卖出:10000 × (0.03% + 0.1%) = 13元
- 总成本:16元(占比0.16%,下文统一用此数字)
看起来不多,但频繁交易就很可观了。
2. 滑点
理论价格和实际成交价格的差异。
比如:
- 理想:在100元买入
- 实际:可能买在100.1元
大单、流动性差的股票滑点更严重。
8.2 加入成本的回测
# 简化估算:单次完整交易(买入+卖出)成本约0.16%
transaction_cost = 0.0016
# 检测交易(持仓变化)
df['trade'] = df['signal'] != df['signal'].shift(1)
# 扣除成本
df['net_return'] = df['strategy_return'] - df['trade'] * transaction_cost
加入成本后,收益会明显下降。我估算过:
- 不考虑成本:年化32.67%
- 考虑成本(每日换仓约20%仓位):年化约29%
差了约3~4个百分点。策略每天只换仓Top20%的股票,换手率比全仓每日换仓低很多,成本影响相对可控。
九、踩过的坑
做回测的时候踩了不少坑,记录一下。
9.1 坑1:忘记按日期排序
最开始写代码的时候,直接用**shift(1)**,结果发现数据对不上。
问题代码:
df['strategy_return'] = df['signal'].shift(1) * df['actual_return']
问题:如果数据没排序,shift(1)会把错误的行对应起来。
解决:
# 必须先排序!
df = df.sort_values(['symbol', 'date']).reset_index(drop=True)
df['strategy_return'] = df['signal'].shift(1) * df['actual_return']
9.2 坑2:多只股票混在一起
如果有多只股票,直接**shift(1)**会把A股票的信号对应到B股票的收益。
问题代码:
df['strategy_return'] = df['signal'].shift(1) * df['actual_return']
问题:
symbol date signal actual_return strategy_return
A 2024-01-01 1 0.02 NaN
A 2024-01-02 -1 0.01 1 × 0.01 = 0.01 ✓
B 2024-01-01 1 -0.01 -1 × (-0.01) = 0.01 ✗ (用了A的信号)
解决:按股票分组
df['strategy_return'] = df.groupby('symbol')['signal'].shift(1) * df['actual_return']
9.3 坑3:第一行数据丢失
用shift(1)`后,第一行会变成NaN。
解决:
# 删除NaN
df = df.dropna(subset=['strategy_return'])
# 或者填充为0
df['strategy_return'] = df['strategy_return'].fillna(0)
我选择删除,因为第一天没有信号,本来就不应该交易。
9.4 坑4:累计收益计算错误
最开始我用的是累加:
# 错误!
df['cumulative_return'] = df['strategy_return'].cumsum()
问题:收益率应该是累乘,不是累加。
比如:
- 第1天涨10%:1.1
- 第2天涨10%:1.1 × 1.1 = 1.21(涨21%)
- 如果用累加:0.1 + 0.1 = 0.2(涨20%)✗
正确做法:
df['cumulative_return'] = (1 + df['strategy_return']).cumprod()
十、策略优化方向
通过回测发现了几个可以改进的地方。这些还没实现,先记下来。
10.1 降低交易频率
每天换仓成本太高,改成每周调整一次:
# 每周一调仓
if date.weekday() == 0:
rebalance()
这样交易次数减少80%,成本大幅下降。
10.2 设置止损止盈
当亏损或盈利达到一定比例,强制平仓:
if current_profit < -0.05: # 亏5%止损
sell()
if current_profit > 0.10: # 赚10%止盈
sell()
能控制最大回撤。
10.3 仓位管理
不是全仓买入,而是根据预测的置信度调整仓位:
# 预测涨幅越大,买入越多
position_size = min(predicted_return / 0.05, 1.0) # 最多满仓
降低风险。
十一、回测的局限性
回测结果再好,也不代表未来一定能赚钱。
11.1 几个局限
1. 历史不会重演
过去有效的规律,未来可能失效。比如2015年牛市的策略,2018年熊市就不行了。
2. 市场适应
如果所有人都用同样的策略,策略就会失效。这是量化交易的悖论。
3. 黑天鹅事件
历史数据里没有的极端情况。比如2020年疫情,模型完全没见过。
4. 回测只能排除坏策略
回测结果好,不代表策略好。但回测结果差,策略肯定有问题。
11.2 正确的态度
- 回测是必要的,但不是充分的
- 多个时间段都测一测
- 压力测试(极端情况下会怎样)
- 小资金实盘验证
十二、我的回测结果
最终的策略(LightGBM预测 + 每日Top20%做多):
初始资金: 100,000
回测周期: 2022-07-08 到 2024-12-24(样本外,约2.5年)
股票池: 795只A股
关键指标:
总收益率: 95.82%
年化收益率: 32.67%
波动率: 22.88%
夏普比率: 1.2970(年化)
最大回撤: -25.42%
胜率: 51.75%
vs 买入持有:
年化收益提升: +23.32%
夏普比率提升: +315%
最大回撤: 略高(-25.42% vs -23.04%)
策略在样本外2.5年的测试期内大幅跑赢市场,夏普比率达到1.30,说明模型的选股能力是真实有效的。
说明:这里的夏普比率是年化的。如果看到其他地方(比如第4篇)有不同的夏普比率数值,可能是日度或其他周期的,不能直接对比。
十三、总结
回测系统是检验策略的最后一关。
核心逻辑:
- 只用过去的信息做决策
- 用未来的数据验证结果
- shift(1)避免未来函数
关键指标:
- 总收益率:赚了多少
- 年化收益率:标准化后的收益
- 夏普比率:风险调整后收益(最重要)
- 最大回撤:最大风险
- 胜率:赚钱的概率
踩过的坑:
- 忘记排序导致数据错位
- 多只股票混在一起,没有按symbol分组shift
- 累计收益用了累加而不是累乘
- 预测目标(5日收益)和回测收益(1日收益)不对齐,导致策略失效
- 忘记考虑交易成本
注意事项:
- 避免未来函数
- 考虑交易成本
- 多时间段验证
- 理解回测的局限性
下一篇是整个系列的最后一篇,会做个项目复盘,总结技术亮点、踩过的坑,以及如何把这些技术迁移到其他场景。
下一篇: 第7篇:项目复盘与技术总结