Open Source, Open Future!
  menu
118 文章
ღゝ◡╹)ノ❤️

第6篇:回测系统 - 验证策略的真实表现

上一篇: 第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 几个局限

  1. 历史不会重演

过去有效的规律,未来可能失效。比如2015年牛市的策略,2018年熊市就不行了。

  1. 市场适应

如果所有人都用同样的策略,策略就会失效。这是量化交易的悖论。

  1. 黑天鹅事件

历史数据里没有的极端情况。比如2020年疫情,模型完全没见过。

  1. 回测只能排除坏策略

回测结果好,不代表策略好。但回测结果差,策略肯定有问题。

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篇:项目复盘与技术总结