上一篇: 第5篇:运筹优化
一、写在前面
前面几篇搭建了完整的预测和优化流程:
- 第1篇:数据获取和清洗
- 第2篇:特征工程
- 第3篇:预测模型
- 第4篇:运筹优化
但有个关键问题还没回答:这套策略真的能赚钱吗?
这就需要回测系统。用历史数据模拟实际交易,看看策略在过去的表现如何。如果在过去都不行,未来大概率也不行。
二、为什么需要回测
刚开始做这个项目的时候,我训练完模型看到方向准确率58%,就觉得已经很不错了。毕竟比随机猜(50%)高了8个百分点。
但后来发现,准确率高不代表能赚钱。
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:
- 执行交易: 用今天开盘价买入
- 结算: 用今天收盘价计算盈亏
- 能看到: 2022-01-04及之前的数据
这个时间线看起来简单,但实际编码时很容易出错。最常见的错误就是不小心用了未来数据。
四、简化版回测实现
我实现了一个简化版的回测系统,主要验证预测模型的效果。
4.1 策略逻辑
# 交易信号:预测涨就买入(1),预测跌就做空(-1)
df['signal'] = np.where(df['predicted_return'] > 0, 1, -1)
# 策略收益 = 信号 × 实际收益
# signal.shift(1) 是关键:用昨天的信号,对应今天的收益
df['strategy_return'] = df['signal'].shift(1) * df['actual_return']
为什么要shift(1)?
这是避免未来函数的关键。看个例子:
日期 预测 信号 实际收益 策略收益
1月1日 涨2% 1 - -
1月2日 跌1% -1 涨1.5% 1×1.5%=1.5% (用1月1日的信号)
1月3日 涨3% 1 跌0.5% -1×(-0.5%)=0.5% (用1月2日的信号)
shift(1) 保证了用"昨天的预测"对应"今天的收益",避免未来函数。
4.2 完整代码
def simple_backtest_demo(df):
"""
简化版回测
策略:预测涨就做多(1),预测跌就做空(-1)
"""
# 按日期排序(很重要!)
df = df.sort_values(['symbol', 'date']).reset_index(drop=True)
# 交易信号
df['signal'] = np.where(df['predicted_return'] > 0, 1, -1)
# 策略收益(shift(1)避免未来函数)
df['strategy_return'] = df['signal'].shift(1) * df['actual_return']
# 按日期聚合(多只股票的平均)
daily_returns = df.groupby('date').agg({
'actual_return': 'mean', # 市场平均收益
'strategy_return': 'mean' # 策略收益
})
# 计算累计净值
daily_returns['market_equity'] = (1 + daily_returns['actual_return']).cumprod() * 100000
daily_returns['strategy_equity'] = (1 + daily_returns['strategy_return']).cumprod() * 100000
return daily_returns
4.3 代码逐行解释
第1步:排序
df = df.sort_values(['symbol', 'date']).reset_index(drop=True)
这步很重要!必须按时间顺序排序,否则 shift(1)会错位。
第2步:生成信号
df['signal'] = np.where(df['predicted_return'] > 0, 1, -1)
- 预测涨(predicted_return > 0):信号=1(做多)
- 预测跌(predicted_return <= 0):信号=-1(做空或不持有)
第3步:计算策略收益
df['strategy_return'] = df['signal'].shift(1) * df['actual_return']
这行是核心。shift(1) 确保时间对齐:
- 用昨天的信号
- 乘以今天的实际收益
第4步:聚合和累计
daily_returns = df.groupby('date').agg({
'actual_return': 'mean', # 市场平均收益
'strategy_return': 'mean' # 策略收益
})
daily_returns['strategy_equity'] = (1 + daily_returns['strategy_return']).cumprod() * 100000
cumprod() 是累积乘积。比如:
- 第1天:1.02(涨2%)
- 第2天:1.02 × 1.03 = 1.0506(累计涨5.06%)
- 第3天:1.0506 × 0.99 = 1.040(累计涨4%)
4.4 运行结果
假设初始资金10万:
【绩效对比】
市场表现(买入持有):
total_return: 12.45%
annual_return: 8.23%
volatility: 18.92%
sharpe_ratio: 0.2763
max_drawdown: -15.34%
策略表现(预测+交易):
total_return: 18.67%
annual_return: 12.15%
volatility: 19.45%
sharpe_ratio: 0.4708
max_drawdown: -12.21%
分析:
- 年化收益:12.15% vs 8.23%,提升了3.92个百分点
- 夏普比率:0.4708 vs 0.2763,提升70%(这个更重要)
- 最大回撤:-12.21% vs -15.34%,风险更低
策略跑赢了市场,而且风险调整后的收益更好。
五、关键指标详解
回测有几个核心指标,每个都很重要。
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:优秀(专业机构水平)
我的策略夏普比率0.47,算是及格了。
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
注意:目标变量可以用未来数据(因为是用来训练的),但特征和信号不能用。
7.3 如何检查未来函数
一个简单的检查方法:
前视偏差测试
# 把数据分成两段
train_end = '2024-06-30'
test_start = '2024-07-01'
# 只用train_end之前的数据训练模型
# 用test_start之后的数据测试
# 如果测试集准确率突然暴跌,可能有未来函数
如果训练集准确率80%,测试集掉到50%,基本可以肯定有未来函数。
八、交易成本的影响
简化版回测没考虑成本,实际交易有很多成本。
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.15%
transaction_cost = 0.0015
# 检测交易(持仓变化)
df['trade'] = df['signal'] != df['signal'].shift(1)
# 扣除成本
df['net_return'] = df['strategy_return'] - df['trade'] * transaction_cost
加入成本后,收益会明显下降。我试过:
- 不考虑成本:年化12.15%
- 考虑成本:年化9.83%
差了2%多。如果频繁交易(每天都换仓),可能全被成本吃掉了。
九、踩过的坑
做回测的时候踩了不少坑,记录一下。
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()
能控制最大回撤。
3. 仓位管理
不是全仓买入,而是根据预测的置信度调整仓位:
# 预测涨幅越大,买入越多
position_size = min(predicted_return / 0.05, 1.0) # 最多满仓
降低风险。
十一、回测的局限性
回测结果再好,也不代表未来一定能赚钱。
11.1 几个局限
- 历史不会重演
过去有效的规律,未来可能失效。比如2015年牛市的策略,2018年熊市就不行了。
- 市场适应
如果所有人都用同样的策略,策略就会失效。这是量化交易的悖论。
- 黑天鹅事件
历史数据里没有的极端情况。比如2020年疫情,模型完全没见过。
- 回测只能排除坏策略
回测结果好,不代表策略好。但回测结果差,策略肯定有问题。
11.2 正确的态度
- 回测是必要的,但不是充分的
- 多个时间段都测一测
- 压力测试(极端情况下会怎样)
- 小资金实盘验证
十二、我的回测结果
最终的策略(预测模型 + OR-Tools优化):
初始资金: 100,000
回测周期: 2022-01-04 到 2024-12-31
关键指标:
总收益率: 18.67%
年化收益率: 12.15%
波动率: 19.45%
夏普比率: 0.4708(年化)
最大回撤: -12.21%
胜率: 57.89%
vs 买入持有:
年化收益提升: +3.92%
夏普比率提升: +70%
最大回撤降低: -3.13%
算是及格了,但还有很大提升空间。
说明:这里的夏普比率是年化的。如果看到其他地方(比如第4篇)有不同的夏普比率数值,可能是日度或其他周期的,不能直接对比。
十三、总结
回测系统是检验策略的最后一关。
核心逻辑:
- 只用过去的信息做决策
- 用未来的数据验证结果
- shift(1)避免未来函数
关键指标:
- 总收益率:赚了多少
- 年化收益率:标准化后的收益
- 夏普比率:风险调整后收益(最重要)
- 最大回撤:最大风险
- 胜率:赚钱的概率
踩过的坑:
- 忘记排序导致数据错位
- 多只股票混在一起
- 累计收益用了累加而不是累乘
- 忘记考虑交易成本
注意事项:
- 避免未来函数
- 考虑交易成本
- 多时间段验证
- 理解回测的局限性
下一篇是整个系列的最后一篇,会做个项目复盘,总结技术亮点、踩过的坑,以及如何把这些技术迁移到其他场景。
下一篇: 第7篇:项目复盘与技术总结