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

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

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